Passing Callbacks and Render Props Across Remotes #

Passing a function into a federated remote works exactly like passing one to a local component, right up to the point where the remote keeps it after unmount, or the host recreates it every render and re-renders the remote with it.

The mechanics are ordinary React; the failure modes are not, because the two sides are built and deployed separately and neither can see the other’s code. This page covers the contract, the identity discipline, and the teardown rules. It extends alternatives to prop drilling in distributed UIs.

Choosing between a callback, an event and a render prop Each mechanism trades coupling against control, and the deciding question is whether the host needs an answer or merely needs to know. The remote needs to tell the host something happened. How? This instance, host needs an answer Callback prop Synchronous, scoped Couples to render lifecycle Unknown or absent consumers Event on the bus Fire and forget Survives a late subscriber Host must own a region's look Render prop Highest coupling Always ship a default
If the remote would still work with the prop absent, it is almost certainly an event rather than a callback.

Prerequisites #

Step 1 — Decide whether a callback is the right mechanism at all #

A callback couples the remote to the host’s render lifecycle. Sometimes that is exactly right and sometimes an event is a better fit.

Use a callback when the host needs a synchronous answer, when the interaction is scoped to this instance of the remote, or when the remote needs a value the host alone can produce. Use an event when several unknown parties may care, when the remote does not need a response, or when the consumer might not be mounted — the late-subscriber case that event bus patterns exist for.

A useful test: if the remote would still work with the callback absent, it is probably an event.

Step 2 — Pass data, never capabilities #

What a callback is allowed to close over Both versions work; only one keeps the host's store, router and internal shapes out of the remote's dependency surface. Passing capabilities Callback closes over the store and router Remote now depends on host internals A host refactor breaks the remote The seam is an implementation contract Passing data Callback receives serialisable data Host decides what the event means Host internals stay private The seam is a data contract
The test is whether the remote could be reimplemented by another team without knowing what the host uses internally.

The most consequential decision is what the function is allowed to close over.

// WRONG — the remote now holds a handle to the host's store and router.
<CartRemote
  onCheckout={() => { store.dispatch(startCheckout()); navigate('/pay'); }}
  getSession={() => sessionStore.raw}
/>

// RIGHT — the remote reports what happened; the host decides what that means.
<CartRemote
  onCheckout={(items: CartItem[]) => {
    store.dispatch(startCheckout(items));
    navigate('/pay');
  }}
/>

Both compile and both work. The difference is that in the first version the remote has been handed the host’s store and router, and any future change to either becomes a change the remote can observe. The seam has widened from a data contract to an implementation contract.

The rule that keeps this stable: a callback receives serialisable data describing what happened and returns either nothing or serialisable data. A function that returns another function, or that hands the remote an object with methods, is a capability and belongs on the other side of the boundary.

Step 3 — Keep callback identity stable #

A host that recreates its callbacks every render re-renders the remote every render, and neither side can see why.

// host — stable identity across renders
const handleCheckout = useCallback((items: CartItem[]) => {
  dispatch(startCheckout(items));
  navigate('/pay');
}, [dispatch, navigate]);

return <CartRemote onCheckout={handleCheckout} />;
// remote — memoised so a changed identity is the only reason to re-render
export default memo(function Cart({ items, onCheckout }: CartProps) {
  return <button onClick={() => onCheckout(items)}>Checkout</button>;
});

Both halves are needed. useCallback without memo still re-renders the remote; memo without useCallback compares a new function every time and never bails out.

This matters more across a federation boundary than locally, because the remote’s render may be substantially more expensive than anything in the host’s own tree, and nobody profiling the host will see the cost.

Step 4 — Type the contract explicitly #

An implicit callback signature is a contract nobody wrote down, which means both sides will eventually assume different things.

// @acme/cart-types — published by the remote, consumed by the host
export interface CartItem { readonly sku: string; readonly quantity: number }

export interface CartProps {
  items: readonly CartItem[];
  /** Called when the user confirms. Must not throw. */
  onCheckout: (items: readonly CartItem[]) => void;
  /** Optional: return false to prevent removal. Must be synchronous. */
  onBeforeRemove?: (sku: string) => boolean;
  /** Optional slot: rendered in place of the default empty state. */
  renderEmpty?: () => React.ReactNode;
}

The comments are part of the contract. “Must not throw” and “must be synchronous” are exactly the constraints that are obvious to the author, invisible to the consumer, and discovered in production.

Mark data as readonly. A remote that mutates an array the host passed will corrupt host state in a way that is genuinely difficult to trace back across the boundary.

Step 5 — Handle a callback that throws #

A callback runs inside the remote’s render tree, so a host bug surfaces as a remote crash and gets attributed to the wrong team.

// remote — never let a host callback take the remote down
function safeInvoke<T extends unknown[]>(fn: ((...a: T) => void) | undefined, ...args: T) {
  try {
    fn?.(...args);
  } catch (error) {
    // Attribute it correctly: this is the host's code failing, not ours.
    reportEvent('host-callback-threw', { remote: 'cart', error: String(error) });
  }
}

<button onClick={() => safeInvoke(onCheckout, items)}>Checkout</button>

Tagging the report as a host callback failure is the point. Without it, the error lands in the cart remote’s error budget and the cart team spends an afternoon on a bug in someone else’s code.

Step 6 — Use render props sparingly, and type them #

Four levels of host-to-remote coupling Each step down this table gives the host more control over the remote's internals, and only the first two are safe to rely on long term. Pattern Coupling Rule Callback prop low data in, nothing or data out Optional render prop medium always ship a default Required render prop high avoid — the remote is now incomplete Passing a store or router highest never — that is a capability
A required render prop means the remote cannot render on its own, which breaks standalone development and every test double.

A render prop lets the host control a region inside the remote, which is powerful and couples the two more tightly than a callback does.

// remote — a narrow, documented slot
export default function Cart({ items, renderEmpty, renderItem }: CartProps) {
  if (!items.length) return <>{renderEmpty?.() ?? <DefaultEmpty />}</>;
  return (
    <ul>
      {items.map((item) => (
        <li key={item.sku}>{renderItem?.(item) ?? <DefaultItem item={item} />}</li>
      ))}
    </ul>
  );
}

Two rules keep render props from becoming a maintenance problem. Always provide a default, so a host that passes nothing still gets a complete component and the prop stays genuinely optional. And pass the render prop only the data it needs — a renderItem that receives the remote’s internal state object has exported that state as part of the API.

Prefer a callback plus the remote’s own rendering where you can. A render prop is right when the host must own the visual treatment of a region; it is overkill when the host merely wants to know something happened.

Step 7 — Release references on unmount #

A remote that stores a host callback outside React’s lifecycle keeps the host’s closure — and everything it captured — alive after unmount.

// remote — the callback must not outlive the component
export default function Cart({ onCheckout }: CartProps) {
  const latest = useRef(onCheckout);
  useEffect(() => { latest.current = onCheckout; }, [onCheckout]);

  useEffect(() => {
    const handler = () => latest.current?.([]);
    window.addEventListener('acme:clear-cart', handler);
    // Without this, every mount leaves another handler holding a host closure.
    return () => window.removeEventListener('acme:clear-cart', handler);
  }, []);

  return <CartList />;
}

The ref-plus-effect pattern gives a stable handler that always calls the current callback, so the listener does not need re-attaching when the host’s function identity changes. The cleanup is what prevents the leak.

On a federated page remotes mount and unmount on every navigation, so a leaked handler accumulates once per navigation for the life of the session.

Verification #

Troubleshooting #

Symptom: the remote re-renders on every host keystroke.

Diagnosis: an inline arrow function is recreated each render. Fix: wrap it in useCallback and memoise the remote’s exported component.

Symptom: the host’s state is mutated by the remote.

Diagnosis: an array or object was passed without readonly and the remote sorted or spliced it in place. Fix: mark the props readonly and pass copies for anything the remote is expected to modify.

Symptom: memory grows with each navigation.

Diagnosis: the remote registered a listener holding a host callback and never removed it. Fix: return a cleanup from every effect that registers something, as in step 7.

Symptom: an error in the host’s callback is attributed to the remote.

Diagnosis: the callback runs inside the remote’s tree, so its boundary catches it. Fix: wrap host callbacks in the remote and tag the report as a host failure.

Async callbacks and the unmount race #

One case deserves its own note because it appears in almost every federated codebase eventually: a callback that returns a promise the remote awaits.

The remote calls the host’s async function, awaits it, and updates its own state with the result — and by the time the promise resolves the remote may have unmounted, because the user navigated away. React will warn, and in a federated page this happens far more often than in a monolith, since remotes unmount on every route change rather than only when the user leaves the app.

The fix is to make the remote’s own lifecycle the authority rather than the promise’s. An AbortController created on mount and aborted on cleanup gives the remote a signal it can check after every await, and passing that signal into the host’s callback lets a well-behaved host cancel its own work too. Document it in the prop’s type — a host that ignores the signal still works, it simply does more work than necessary.