Configuring Shared Singletons to Deduplicate React #

When a host and its remotes each ship their own copy of React, you pay for the same library two or three times over and trip the dreaded “Invalid hook call” error the moment a remote component mounts inside the host tree.

This guide walks through configuring react, react-dom, a router, and a state library as Module Federation singletons so exactly one instance loads at runtime — eliminating the duplicate bundles covered in Avoiding Bundle Duplication and the hook crashes that come with them.

Why two Reacts breaks everything #

React stores hook state in a module-level “current dispatcher”. If a remote bundles its own React, its components read from a different dispatcher than the one the host rendered with. The reconciler sees a null dispatcher and throws Invalid hook call. You are not just wasting kilobytes — you have two incompatible runtimes fighting over one component tree.

Duplicate React vs Shared Singleton Without sharing, host and two remotes each bundle React; with a singleton, all three reference one shared React instance. Without sharing With singleton Host React #1 Remote A React #2 Remote B React #3 3 copies hook crash Host Remote A Remote B Shared React #1
Left: every app bundles its own React, wasting bytes and crashing hooks. Right: a singleton routes all three to one shared instance.

Prerequisites #

A baseline of measurement helps before you start. Use Measuring Bundle Size Impact of Shared Dependencies to capture the “before” numbers so you can prove the saving later.

Step-by-step implementation #

1. Declare the singletons in the host #

The host owns the canonical version. Mark react and react-dom as a matched pair of singletons — they must always travel together.

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

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        catalog: 'catalog@http://localhost:3001/remoteEntry.js',
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react, // e.g. "18.2.0"
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
        },
        'react-router-dom': {
          singleton: true,
          requiredVersion: deps['react-router-dom'],
        },
        zustand: {
          singleton: true,
          requiredVersion: deps.zustand,
        },
      },
    }),
  ],
};

Reading requiredVersion from package.json keeps the range honest — it can never drift away from what is actually installed.

2. Mirror the exact same shared block in every remote #

The remote must declare the same packages as singletons. If a remote omits react from shared, it bundles its own copy and the singleton guarantee collapses.

// catalog/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/ProductList',
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
        'react-router-dom': { singleton: true, requiredVersion: deps['react-router-dom'] },
        zustand: { singleton: true, requiredVersion: deps.zustand },
      },
    }),
  ],
};

Extract this block into a shared federation.shared.js file imported by both configs so the two can never silently diverge.

3. Align the requiredVersion ranges #

requiredVersion decides whether a remote will accept the host’s instance. If the host advertises 18.2.0 and a remote requires ^17, the remote refuses the shared copy and loads its own. Pin both sides to the same range.

// federation.shared.js — single source of truth, imported by host and remotes
const deps = require('./package.json').dependencies;

module.exports = {
  react: { singleton: true, requiredVersion: deps.react },
  'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
  'react-router-dom': { singleton: true, requiredVersion: deps['react-router-dom'] },
  zustand: { singleton: true, requiredVersion: deps.zustand },
};

When ranges still clash across teams, the deep-dive in Resolving Version Conflicts in Shared React Libraries covers negotiation strategies beyond a simple pin.

4. Decide eager (host) vs lazy (remote) #

eager: true forces a dependency into the initial chunk synchronously. Use it only on the host, and only when the host itself renders React before any remote loads. Remotes must stay lazy (eager: false, the default) so they consume the host’s instance instead of forcing their own into a startup chunk.

// host: eager so React is ready before the first synchronous render
shared: {
  react: { singleton: true, requiredVersion: deps.react, eager: true },
  'react-dom': { singleton: true, requiredVersion: deps['react-dom'], eager: true },
}

If your host bootstraps asynchronously (the recommended bootstrap.js + dynamic import('./bootstrap') pattern), keep eager: false everywhere and let the share scope resolve lazily. Never set eager: true in a remote — two eager copies cannot negotiate and Webpack throws a consumption error.

5. Bootstrap the host asynchronously #

The async boundary lets Module Federation populate the share scope before any React code runs. This is what makes lazy singletons work without an eager flag.

// host/src/index.js
import('./bootstrap');
// host/src/bootstrap.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(<App />);

Verification #

One React chunk in the Network tab #

Open Chrome DevTools, filter the Network tab by react, and load the host with a remote mounted. You should see a single react vendor chunk fetched once (a 200, then 304 on reload) — not one per app.

Confirm a single React identity at runtime #

Stash React on window from both the host and a remote module, then assert they are the same object reference.

// in host bootstrap
import React from 'react';
window.__HOST_REACT__ = React;

// inside an exposed remote module
import React from 'react';
console.assert(
  window.__HOST_REACT__ === React,
  'FAIL: remote loaded a second React instance'
);
console.log('React instances identical:', window.__HOST_REACT__ === React);

A true result means the singleton is working. false means a duplicate slipped through.

Hooks work across the boundary #

Render a remote component that uses useState inside the host tree and interact with it. If state updates without an Invalid hook call error in the console, host and remote share one dispatcher.

// catalog/src/ProductList.jsx — exposed remote component
import React, { useState } from 'react';

export default function ProductList() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>Clicked {count}</button>;
}

Measure the saving #

Diff the bundle before and after. With three apps deduplicated, you typically reclaim two full copies of react + react-dom (~130 KB minified each) from the network total — quantify it with the workflow in Measuring Bundle Size Impact of Shared Dependencies.

Troubleshooting #

Symptom: “Invalid hook call. Hooks can only be called inside the body of a function component.”

Diagnosis: two React instances are live — the singleton did not take. Run the identity check from above; if it logs false, a remote is bundling its own React. Fix: confirm react is in the remote’s shared block with singleton: true, and that requiredVersion overlaps the host’s version.

Symptom: console warning “No satisfying version of react found / unable to use shared module”.

Diagnosis: the host and remote requiredVersion ranges do not intersect (e.g. host 18.2.0, remote ^17). Fix: align both to the same range via the shared federation.shared.js module, then rebuild both apps. For genuinely mixed-major scenarios, see Resolving Version Conflicts in Shared React Libraries.

Symptom: build error “Shared module react is eager but cannot be consumed eagerly”.

Diagnosis: eager: true is set in more than one container, or set in a remote. Eager singletons can’t negotiate. Fix: make at most one app (the host) eager, or switch everything to the async bootstrap.js pattern and drop eager entirely.

Symptom: hooks crash only after a remote mounts, even though react is shared.

Diagnosis: react-dom was not shared alongside react. The two must be the same instance — sharing one without the other reintroduces a split runtime. Fix: add react-dom to the shared block with identical singleton and requiredVersion settings in every app.