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.
Prerequisites #
- React 18+ shared as a singleton across host and remotes.
- Types published for the exposed component, per sharing TypeScript types across federated remotes.
- An error boundary around each remote, since a callback that throws will throw inside the remote’s tree.
- Agreement that callbacks carry data, not capabilities — the rule step 2 makes concrete.
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 #
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 #
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 #
- Identity is stable: log the callback’s identity in the remote across host re-renders. It should not change.
- Memoisation works: re-render the host without changing props and confirm the remote does not re-render.
- The contract is enforced: change a callback signature in the published types and confirm the host fails to typecheck.
- A throwing callback is contained: make the host’s callback throw and confirm the remote keeps rendering and reports the failure as a host callback.
- No leaks: mount and unmount the remote twenty times and confirm listener count and heap size return to baseline.
- Defaults hold: render the remote without any optional render prop and confirm it is fully functional.
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.