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.
Prerequisites #
- Webpack 5.x (or
@module-federation/enhanced) withModuleFederationPlugin. reactandreact-domat the same version in host and every remote — e.g.18.2.0everywhere.- A shared router (
react-router-dom@^6) and state library (zustand@^4orredux/react-redux) if you share those. - All shared packages listed as real
dependenciesin eachpackage.jsonso Webpack can read their installed versions.
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.
What a singleton does and does not guarantee #
singleton: true is frequently read as “there will be one copy”, and that is not quite what it means. It means that at most one instance may be used from the share scope, and that a version mismatch produces a console warning rather than a second registration. What it does not do is prevent a copy from being loaded when the negotiation fails outright.
That distinction explains the most confusing symptom in this area: a graph configured entirely with singleton: true that still ends up with two Reacts. When no registered version satisfies a remote’s requiredVersion, the runtime falls back to that remote’s own bundled copy — with a warning nobody sees in production — and both copies proceed to run. The singleton flag was honoured; the range simply could not be met.
Adding strictVersion: true closes it by turning that fallback into a thrown error at load time. The remote fails to mount, an error boundary catches it, and the failure is attributed to the remote and its version rather than surfacing later as an invalid hook call in unrelated code. For a framework dependency that is almost always the right trade: a region that visibly fails is far cheaper to diagnose than a page that works incorrectly.
The third setting worth understanding alongside them is requiredVersion itself. Too narrow and every upgrade becomes a coordinated release across every remote; too wide and incompatible majors negotiate successfully and break at runtime. A caret range on the framework’s current major is the usual starting point, tightened only where a real incompatibility has been observed.
When a second copy is actually correct #
There is one case where duplication is the right answer, and recognising it prevents a lot of wasted effort: a remote that must run inside hosts you do not control.
A widget embedded into a third-party page cannot assume a share scope exists, cannot assume React is registered, and cannot negotiate with a host that knows nothing about federation. It has to bring its own copy. The cost is real — the widget carries the framework’s full weight — and it buys the ability to render anywhere.
The way to keep that from contaminating your own graph is to build the two cases separately. The federated build shares React lazily and ships no copy; the embeddable build bundles everything and is published under a different entry point. Both come from the same source, and neither compromises for the other.