Using Shared Context Providers Across Remotes #

You want a host application to wrap the page in a single Provider — for theme, the current user, or locale — and have every federated remote read that value through useContext without any value being threaded down as props, which only works when React and the context module resolve to one shared singleton instance across the federation boundary.

This is the precise nuance behind the broader guidance in Alternatives to Prop Drilling in Distributed UIs: React Context can cross a Module Federation boundary, but only if the context object and the React runtime backing it are literally the same instances in the host and in every remote. Get the sharing config wrong and the remote silently falls back to its default value.

Why a non-singleton context fails #

A React Context is just an object created by createContext. The Provider and the matching useContext find each other by object identity, not by name. When webpack bundles the context module into the host and again into a remote, you end up with two distinct context objects.

The host renders HostContext.Provider. The remote calls useContext(RemoteCopyOfContext). Because those are different objects, React walks up the fiber tree, finds no matching provider for the remote’s copy, and returns the default value you passed to createContext. No error, just undefined or your default — the hardest kind of bug to spot.

The same trap applies to React itself. Two React copies mean two independent context registries, so even the same context object can’t bridge them. That is why this technique depends entirely on configuring shared singletons to deduplicate React and on managing shared dependencies at runtime.

Shared context singleton across the federation boundary The host wraps the tree in a Provider; a single shared context instance carries the value into two remotes that call useContext. Host shell <ThemeContext.Provider value={dark}> Singleton context one ThemeContext object Remote A useContext(ThemeContext) Remote B useContext(ThemeContext)
One context instance, shared as a singleton, carries the host Provider value into every remote — no props threaded down.

Prerequisites #

Step 1 — Put the context in one shared module #

Create the context in a package both host and remotes depend on. Keep this file tiny and free of side effects so it can be deduplicated cleanly. Note the default value — that fallback is what a standalone remote sees when no host Provider is mounted.

// packages/context/src/theme.tsx
import { createContext, useContext, type ReactNode } from 'react';

export interface Theme {
  mode: 'light' | 'dark';
  user: { id: string; name: string } | null;
  locale: string;
}

// Default value: used by a remote running standalone, with NO host Provider.
const fallback: Theme = { mode: 'light', user: null, locale: 'en-US' };

export const ThemeContext = createContext<Theme>(fallback);

export function ThemeProvider({ value, children }: { value: Theme; children: ReactNode }) {
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

// One hook, imported identically by every app.
export function useTheme(): Theme {
  return useContext(ThemeContext);
}

Step 2 — Mark React and the context as singletons #

In the host, share react, react-dom, and the context package as singletons with strict versions. This is the line that makes object identity hold across the boundary. If you only share React but not @platform/context, the context object still duplicates and the remote reads the default.

// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        cart: 'cart@https://cdn.example.com/cart/remoteEntry.js',
        search: 'search@https://cdn.example.com/search/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
        // The context package MUST be a singleton too — this is the key.
        '@platform/context': { singleton: true, requiredVersion: '^1.0.0', strictVersion: true },
      },
    }),
  ],
};

Step 3 — Mirror the singleton config in every remote #

Each remote declares the same shared block. Mismatched config here is the most common cause of a duplicated context. The remote does not list @platform/context under exposes — it only consumes it.

// cart/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'cart',
      filename: 'remoteEntry.js',
      exposes: { './Cart': './src/Cart' },
      shared: {
        react: { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
        'react-dom': { singleton: true, requiredVersion: '^18.2.0', strictVersion: true },
        '@platform/context': { singleton: true, requiredVersion: '^1.0.0', strictVersion: true },
      },
    }),
  ],
};

Step 4 — Wrap the tree in the host Provider #

The host owns the value and mounts the Provider above the point where remotes render. Because remotes are loaded lazily into this same React tree, they sit inside the provider’s subtree.

// host/src/App.tsx
import { lazy, Suspense, useState } from 'react';
import { ThemeProvider, type Theme } from '@platform/context';

const Cart = lazy(() => import('cart/Cart'));
const Search = lazy(() => import('search/Search'));

export default function App() {
  const [theme] = useState<Theme>({
    mode: 'dark',
    user: { id: 'u-42', name: 'Ada' },
    locale: 'en-GB',
  });

  return (
    <ThemeProvider value={theme}>
      <header>Host shell</header>
      <Suspense fallback={<p>Loading…</p>}>
        <Search />
        <Cart />
      </Suspense>
    </ThemeProvider>
  );
}

Step 5 — Consume the same context identity in the remote #

The remote imports useTheme from the same package name. At runtime, federation resolves that import to the single shared instance the host already loaded, so useContext finds the host’s provider.

// cart/src/Cart.tsx
import { useTheme } from '@platform/context';

export default function Cart() {
  const { mode, user, locale } = useTheme();

  return (
    <section data-theme={mode}>
      <h2>Cart for {user?.name ?? 'guest'}</h2>
      <p>Locale: {locale}</p>
    </section>
  );
}

When the remote runs standalone (its own dev server, no host), the import resolves to the package’s local copy and useContext returns the fallback default — so the remote still renders sensibly during isolated development.

Step 6 — Keep a fallback Provider for standalone dev #

Relying on the bare default is fine, but a tiny dev harness gives a remote realistic values without coupling it to the host. Render it only outside the host.

// cart/src/dev.tsx
import { createRoot } from 'react-dom/client';
import { ThemeProvider } from '@platform/context';
import Cart from './Cart';

createRoot(document.getElementById('root')!).render(
  <ThemeProvider value={{ mode: 'dark', user: { id: 'dev', name: 'Dev' }, locale: 'en-US' }}>
    <Cart />
  </ThemeProvider>,
);

Verification #

Confirm the singleton actually holds — don’t trust that the value rendered by luck.

  1. Read the host value in the remote. Load the host, set the host theme to dark, and assert the remote reflects it:

    // cart/src/Cart.test.tsx
    import { render, screen } from '@testing-library/react';
    import { ThemeProvider } from '@platform/context';
    import Cart from './Cart';
    
    it('reads the host-provided value', () => {
      render(
        <ThemeProvider value={{ mode: 'dark', user: { id: 'u-1', name: 'Ada' }, locale: 'en-GB' }}>
          <Cart />
        </ThemeProvider>,
      );
      expect(screen.getByText('Cart for Ada')).toBeInTheDocument();
      expect(screen.getByText('Locale: en-GB')).toBeInTheDocument();
    });
  2. One provider in React DevTools. Open the running federated app, select a node inside the remote, and inspect the rendered context. The Components panel should show a single ThemeContext.Provider ancestor — not two stacked providers and not the remote falling through to the default.

  3. Identity check in the console. Add a temporary log in both apps: console.log('ctx', ThemeContext). Both should print the same object reference (same internal $$typeof and provider). Two different objects means the singleton failed.

Troubleshooting #

Context returns the default value in the remote.

Diagnosis: the context object is duplicated — almost always because @platform/context is not marked singleton: true in one of the apps, or because React itself is duplicated. Fix: ensure every host and remote shares both react and the context package as singletons with the same requiredVersion, then rebuild. Search the output bundles for a second copy of theme.tsx.

A version-mismatch warning, then the default value.

Diagnosis: strictVersion: true plus incompatible requiredVersion ranges caused federation to load two versions of the package, defeating the singleton. Fix: align the package version across host and all remotes (a workspace resolutions/overrides pin helps), and keep the requiredVersion ranges identical.

Provider mounted below the remote.

Diagnosis: the remote renders above ThemeProvider, or the provider lives inside a different lazy boundary that mounts after the remote. The remote’s useContext runs without a provider ancestor and reads the default. Fix: lift ThemeProvider to the top of the host App, above every <Suspense> that loads remotes.

SSR hydration mismatch.

Diagnosis: the server rendered with one context value (or a different singleton instance) and the client hydrated with another, producing a mismatch warning and a flash of default content. Fix: render the same ThemeProvider value on both server and client, and ensure the shared scope is initialized before hydration so React resolves to one instance on each side. When you need cross-framework or cross-version sharing where a single React tree is impossible, fall back to event bus patterns for decoupled apps instead of context.