Alternatives to Prop Drilling in Distributed UIs #

Prop drilling is a nuisance inside a single application. It becomes a structural failure inside a distributed UI. The moment a piece of state has to travel from a host shell, through a federated remote’s entry component, down into a deeply nested widget that a different team ships on a different release train, the implicit contract created by passing props through every intermediate layer stops holding.

This guide is part of the broader Cross-App State & Context Sharing collection, and it focuses on the concrete alternatives that replace those fragile prop chains. The most direct of those alternatives — wrapping remotes so they read from a host-provided context without manual threading — is covered in depth in using shared context providers across remotes.

What actually breaks #

The failure mode is specific. When you drill a prop through five components, you create a hard dependency on the exact shape and presence of that prop at every level. Inside one codebase a compiler catches a renamed field. Across a federation boundary it does not.

A remote is compiled, versioned, and deployed independently. The host that consumes it may be running a build that is days or weeks out of sync. When the host adds a tenantId prop to a remote’s exposed component, three things can go wrong before any user sees a benefit:

The root issue is that prop drilling assumes one contiguous, co-deployed component tree. Distributed UIs do not have one. The alternatives below all share a single premise: state should be reachable by the component that needs it without depending on the components between them.

Prop drilling chain versus shared state seam Left side shows state passed through four nested components across a federation boundary; right side shows a host-owned store that leaf components read directly. Prop drilling Shared state seam Host shell Remote entry Layout Leaf needs state prop forwarded each level Host store / context Remote A leaf Remote B leaf direct read, no intermediaries
Left: state forwarded through every layer, breaking when any contract drifts. Right: leaves read a host-owned seam directly.

Objectives #

Before writing any code, fix the goals these alternatives are meant to serve:

The four alternatives, and what each one assumes #

There are exactly four mechanisms worth reaching for, and they differ mainly in what they assume both sides share at runtime. Knowing the assumption tells you when each one fails.

  1. A shared singleton React context. The host renders one Context.Provider; every remote calls useContext against the same React instance and reads the live value. This is the lowest-friction option when everything is React, and the one that silently breaks when React is not a true singleton (see the next section).
  2. A shared store exposed as a federation singleton. A Redux or Zustand store lives in one shared module; both host and remotes import the same module instance and subscribe with selectors. This gives you writable state and derived reads, and it survives mixed bundlers because the store is plain JavaScript, not a React internal.
  3. An event bus. A thin publish/subscribe channel carries serialized notifications. No side owns the other’s state; they react to messages. This is the only option that crosses framework and origin boundaries cleanly, via BroadcastChannel.
  4. Custom-element attributes and events. A remote mounts as an opaque tag; the host writes attributes down and listens for CustomEvents back up. The attribute surface is the contract, which makes it the most explicit and the most framework-agnostic of all.

The rest of this guide builds each of these into a runnable seam and shows how to wire them across a real host and remote.

Why a non-singleton context yields defaults in a remote #

This is the single most common surprise in distributed React, so it is worth making the mechanism precise rather than treating it as folklore.

A React context is not a global. createContext(defaultValue) returns an object that carries a private storage slot owned by the React module instance that created it. useContext(MyContext) walks up the fiber tree looking for a matching Provider, and the match is identity-based: it compares the context object reference. The default value is returned only when no matching provider is found above the consumer in that React runtime’s tree.

Now place this across a federation boundary. If the host bundles [email protected] and a remote bundles its own copy of [email protected], you have two distinct module instances at runtime — two different createContext implementations, two different fiber reconcilers. The host imports AuthContext from its React; the remote imports AuthContext from its own React. Even if the source code is byte-identical, the two context objects are different references. The remote’s useContext looks for its provider, finds none above it, and falls back to the default — null, undefined, or whatever placeholder you passed to createContext. Nothing throws. The screen just renders as if logged out.

The fix is to force a single React instance with Module Federation’s shared map and singleton: true. With one React, there is one createContext, one context object reference, and one fiber tree that spans host and remote. The host’s provider is now genuinely above the remote’s consumer, and useContext resolves to the live value. Everything downstream — the singleton store, even the event bus if it ships as a React hook — depends on this same guarantee.

Setup and config #

Every alternative depends on the host and remotes agreeing on a single shared runtime for whatever carries state. Whether that is React, a store library, or a small bus package, it must be marked as a federation singleton. Without this, a “shared” context provider is shared in name only — each remote instantiates its own and the seam silently splits in two.

// webpack.config.js (host shell) — Module Federation 2.0 via @module-federation/enhanced
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        cart: 'cart@https://cdn.example.com/cart/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.3.0', strictVersion: true },
        'react-dom': { singleton: true, requiredVersion: '^18.3.0', strictVersion: true },
        // The state library that backs the seam. Singleton is non-negotiable.
        zustand: { singleton: true, requiredVersion: '^4.5.0', strictVersion: true },
        '@app/store': { singleton: true, requiredVersion: '^3.0.0', strictVersion: true },
      },
    }),
  ],
};

A few of these flags carry real consequences:

If you also run Vite remotes, the same singleton contract applies through the federation plugin’s shared map; the deeper mechanics of keeping a single live instance across both bundlers are covered in synchronizing Redux across micro-frontends.

Choosing the mechanism #

There is no single right answer; the choice follows your topology.

Mechanism Reaches across Best when
Shared context provider Same React instance (singleton) All apps are React and co-located in one DOM tree
Singleton store (Redux/Zustand) Any framework that can import the store You need shared writable state with derived selectors
Event bus Any framework, even cross-origin via BroadcastChannel You need decoupled notifications, not a shared store
Custom element attributes Any framework, including non-JS islands A remote is mounted as an opaque tag with a stable contract

Integration #

The cleanest replacement for prop drilling is a host-owned store that leaf components subscribe to selectively. Zustand makes the selective part trivial, which is why it suits federation: a leaf re-renders only when its slice changes, not when any host state moves.

// @app/store/cart.ts — exposed as a federation singleton
import { create } from 'zustand';

interface CartState {
  itemCount: number;
  tenantId: string;
  addItem: () => void;
}

export const useCartStore = create<CartState>((set) => ({
  itemCount: 0,
  tenantId: 'unknown',
  addItem: () => set((s) => ({ itemCount: s.itemCount + 1 })),
}));

A leaf inside a remote reads exactly the slice it needs, regardless of how deeply it is nested or which intermediary components render it:

// Inside a remote — no props threaded from the entry component
import { useCartStore } from '@app/store/cart';

export function CartBadge() {
  const count = useCartStore((s) => s.itemCount);
  return <span className="badge">{count}</span>;
}

Notice what is absent: no CartBadge ancestor declares, accepts, or forwards itemCount. The intermediary layers stay ignorant, which is exactly the coupling we set out to remove.

A worked example: host provider plus remote consumer #

To make the singleton-context path concrete, here is the full round trip. The host owns the auth state, publishes it once through a provider rendered above the federated boundary, and a remote three layers deep reads it without a single forwarded prop. This is the pattern expanded in using shared context providers across remotes.

First, the shared context lives in its own federation-shared module so both sides import the same object reference:

// @app/contexts/auth.tsx — shared as a singleton alongside react
import { createContext, useContext } from 'react';

export interface AuthValue {
  userId: string | null;
  tenantId: string;
  roles: readonly string[];
}

// The default is what a remote sees when the provider is NOT above it.
export const AuthContext = createContext<AuthValue>({
  userId: null,
  tenantId: 'unknown',
  roles: [],
});

export const useAuth = () => useContext(AuthContext);

The host renders the provider once, wrapping the lazily loaded remote so the provider is unambiguously an ancestor:

// host/App.tsx
import { lazy, Suspense, useState } from 'react';
import { AuthContext, AuthValue } from '@app/contexts/auth';

const CartRemote = lazy(() => import('cart/CartPanel'));

export function App() {
  const [auth] = useState<AuthValue>({
    userId: 'u_8842',
    tenantId: 'acme',
    roles: ['buyer'],
  });

  return (
    <AuthContext.Provider value={auth}>
      <Suspense fallback={<p>Loading cart…</p>}>
        <CartRemote />
      </Suspense>
    </AuthContext.Provider>
  );
}

The remote consumes it from a leaf that knows nothing about how it was mounted:

// cart/CartPanel.tsx (the remote's exposed component)
import { useAuth } from '@app/contexts/auth';

function TenantTag() {
  const { tenantId } = useAuth(); // resolves to "acme", not the default
  return <small>Tenant: {tenantId}</small>;
}

export default function CartPanel() {
  return (
    <section className="cart">
      <h2>Cart</h2>
      <TenantTag />
    </section>
  );
}

If react and @app/contexts/auth are both true singletons, TenantTag renders Tenant: acme. Drop the singleton: true on React and the very same code renders Tenant: unknown — the default — with no error, which is the exact trap the previous section explained.

When the two sides do not share a framework #

When the apps do not share a framework, fall back to a transport that carries serialized payloads rather than live references. A small typed bridge over the DOM works across any boundary, and is the same primitive that underpins the event bus patterns for decoupled apps:

// state-bridge.ts — framework-agnostic, serialization-safe
interface BridgeEnvelope<T> {
  type: string;
  payload: T;
  version: string;
}

export function createStateBridge<T>(channel: string) {
  const target = document.createElement('div'); // private EventTarget

  return {
    subscribe(callback: (data: T) => void) {
      const handler = (e: Event) =>
        callback((e as CustomEvent<BridgeEnvelope<T>>).detail.payload);
      target.addEventListener(channel, handler);
      return () => target.removeEventListener(channel, handler); // always returned
    },
    dispatch(payload: T, version = '1.0') {
      target.dispatchEvent(
        new CustomEvent(channel, { detail: { type: 'STATE_UPDATE', payload, version } }),
      );
    },
  };
}

For remotes that mount as opaque tags rather than imported components, attribute-based transfer is the most robust contract of all, because the attribute surface is the API. A host writes attributes down and listens for events back up, and the remote never imports anything from the host at all:

// host side — drive an opaque remote via the DOM only
const widget = document.querySelector('cart-widget')!;
widget.setAttribute('tenant-id', 'acme');           // state flows down as an attribute
widget.addEventListener('cart:add', (e) => {        // intent flows up as an event
  store.getState().addItem((e as CustomEvent).detail.sku);
});

That pattern — including reflecting attributes into observed properties and emitting typed CustomEvents — is detailed in custom elements for state encapsulation.

Edge cases #

Distributed state introduces failure modes that never appear in a single tree. Handle them explicitly.

Provider mounted below the remote. A subtle variant of the singleton problem: React is a singleton, but the host renders the remote above the provider rather than below it. useContext walks up from the consumer and finds no provider, so it returns the default — identical symptom, different cause. The remote’s exposed component must be a descendant of the provider in the rendered tree, not merely in the same app. When a remote owns a route that renders outside the host’s provider subtree, give the remote its own provider seeded from the singleton store instead.

SSR and hydration. If the host server-renders the provider value but a remote is loaded only on the client, the server HTML carries the default while the client carries the real value, and React logs a hydration mismatch. Serialize the shared value into the initial HTML (a <script> payload the client reads before hydration), seed the store from it synchronously, and render the provider with that value on both server and client. For streaming SSR with React 18/19, ensure the shared value is resolved before the boundary that contains the remote flushes.

Version-skewed payloads. A remote built against an older contract may receive a payload it cannot parse, or emit one the host no longer expects. Validate at the seam and degrade deterministically rather than crashing.

// contract-adapter.ts
import Ajv from 'ajv';

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile({
  type: 'object',
  required: ['userId', 'preferences'],
  properties: {
    userId: { type: 'string' },
    preferences: { type: 'object' },
  },
});

export function hydrateRemoteState(raw: unknown) {
  if (!validate(raw)) {
    console.warn('State contract violation:', validate.errors);
    return { userId: 'anonymous', preferences: {} }; // deterministic fallback
  }
  return raw as { userId: string; preferences: Record<string, unknown> };
}

Serialization boundaries. Anything crossing a BroadcastChannel, postMessage, or custom-element attribute must be plain, cloneable data. Functions, class instances, and Symbols vanish silently. Flatten to JSON-safe objects before dispatch, and resolve circular references with ID-based lookups instead of passing the object graph.

Partial loads. A remote may fail to fetch while the host renders normally. The leaf that reads shared state must tolerate a remote that never mounts — render a fallback, and never let one missing subscriber stall the store.

Stale subscriptions. During route transitions a remote can unmount while a dispatch is in flight. Subscriptions must be torn down on unmount, which is why every subscribe above returns its own cleanup.

Testing and validation #

Treat the seam as a contract and test it as one.

  1. Singleton assertion test. In a smoke test, render the host provider and a remote consumer together and assert the consumer reads the live value, not the default. This single test catches the most expensive regression — a shared config change that quietly de-singletons React.
  2. Contract tests assert that the host’s emitted payload satisfies the schema each remote consumes, and vice versa. Run them in CI before any remote is promoted, so a breaking shape change fails the pipeline rather than production.
  3. Subscription lifecycle tests mount and unmount a remote repeatedly, then assert the store’s listener count returns to baseline — the cheapest guard against the leak pattern in the pitfalls table below.
  4. Latency and partial-failure simulations in Playwright inject network delay and 404s for remoteEntry.js, then verify the host still renders its fallback and the store stays consistent.
  5. Reconciliation tests fire conflicting updates from two remotes and assert a single deterministic result, confirming there is genuinely one writable store rather than two singletons that drifted apart.

For debugging, wrap the store or bus in a thin middleware that logs payload size, dispatch timestamp, and live subscriber count. Snapshot the heap in DevTools before and after a few route transitions; a steadily climbing listener count is the unmistakable signature of missing cleanup.

Deployment #

The seam is a shared contract, so its rollout needs the same care as a shared API.

Because singletons are resolved at runtime from whichever bundle loads first, deployment order matters: ship the host’s new store version before the remotes that depend on it, or keep the store backward-compatible until every remote has caught up.

Common pitfalls #

Issue Root cause and resolution
Context value reads as the default inside a remote React is not a true singleton, so the remote uses its own instance and never sees the host’s provider. Mark react and react-dom singleton: true with strictVersion: true in every federation config.
Context default even with a singleton React The provider is rendered below the remote, not above it, so useContext finds no ancestor provider. Ensure the remote’s exposed component is a descendant of the provider, or give the remote its own provider seeded from the store.
Hydration mismatch on SSR Server rendered the context default while the client had the real value. Serialize the shared value into the initial HTML, seed the store synchronously, and render the provider with the same value on both sides.
Remote fails to load with strictVersion Host and remote pin incompatible React majors (e.g. 18 vs 19). Align requiredVersion across all apps; treat a React major bump as a coordinated, host-first rollout.
Two stores that drift apart The store package was not shared as a singleton, so each remote instantiated its own. Add it to the shared map with singleton: true and audit package.json resolutions across all apps.
Memory leak on route changes Subscriptions were created but never torn down. Always return an unsubscribe function from subscribe and invoke it in the component’s cleanup (useEffect return).
Silent data loss across the boundary A function, class instance, or Symbol was sent over a serializing transport. Flatten payloads to plain JSON before dispatch and validate on receipt.
Whole-page re-render from one shared value The value was threaded as a prop, re-rendering every intermediary. Move it into a store and subscribe with a narrow selector so only the leaf re-renders.

FAQ #

Can I use React Context across Module Federation boundaries?

Yes, but only when React itself is a federation singleton. Context is just a value carried by a specific React instance; if a remote bundles its own React, the host’s provider is invisible to it and useContext returns the default. Once react and react-dom are shared as singletons, a host-rendered provider works in remotes exactly as it would in one app — which is the basis of using shared context providers across remotes.

Should I reach for a store or an event bus?

Use a store when remotes need shared, writable state with derived reads — a cart, a current tenant, a feature-flag set. Use an event bus when remotes need to be notified of something without owning or reading shared state, and especially when they may not share a framework or even an origin.

My React is a singleton but the remote still reads the default — what now?

Check where the provider is mounted relative to the remote. useContext resolves by walking up the rendered tree, so a provider that sits beside or below the remote will never be found. The remote’s exposed component must render as a descendant of the provider. If the remote owns a top-level route that escapes the host’s provider, give that route its own provider hydrated from the singleton store instead of relying on the host’s tree.

What is the performance cost of replacing prop drilling?

It is usually a net win. Prop drilling re-renders every intermediary on each change; a selector-based store re-renders only the leaf that reads the changed slice. The cost shifts from render cycles to payload validation and event dispatch, both of which are far cheaper than reconciling a deep tree.

How do I keep the host and a lagging remote compatible?

Version the payload and validate it at the seam with a deterministic fallback, never throwing into the UI. Keep the store backward-compatible across one release cycle so remotes can catch up, and gate any breaking transport change behind a feature flag you can revert instantly.