Sharing a Singleton React Instance Across Remotes
When a federated remote ships its own copy of React, the page ends up with two React runtimes, two ReactCurrentDispatcher internals, and the immediate symptom Invalid hook call. Hooks can only be called inside the body of a function component. This guide shows how to force one React instance across every host and remote using shared: { react: { singleton: true, requiredVersion } }, how version reconciliation actually resolves, and how to choose eager versus lazy sharing. For the underlying shared-scope mechanics, read Module Federation and Micro-Frontend Architectures first.
Prerequisites & reproducible setup
You need a host and at least one remote, each with React 18 declared. The duplicate-React failure reproduces fastest when the two package.json files pin slightly different React versions, so set that up deliberately:
# host/
npm i react@18.3.1 react-dom@18.3.1
# remote/ — a different patch to force version negotiation
npm i react@18.2.0 react-dom@18.2.0
Tool versions: Webpack 5.80+ with webpack.container.ModuleFederationPlugin, or Vite 5.x with @originjs/vite-plugin-federation 1.3.x (build + vite preview, since that plugin is build-only). Node 18 or 20. Confirm the conflict exists before fixing it:
npm ls react react-dom # run in each package; mismatched versions confirm the setup
Diagnosis workflow
Work through these in order; each step narrows where the second React is coming from.
- Read the exact error.
Invalid hook callwith the three-bullet React explanation almost always means “more than one copy of React” (the third bullet).Cannot read properties of null (reading 'useState')is the same root cause seen through a different stack. - Count React instances at runtime. In the browser console, before any fix, check whether the remote brought its own copy:
For Vite federation, search the Network tab for more than one// paste in DevTools after the remote mounts console.log(Object.keys(window.__webpack_share_scopes__?.default?.react ?? {})); // more than one version key => duplicate React in the share scopereactorreact-domchunk requested across origins. - Confirm both sides declare
reactas shared. A remote that omitsreactfromsharedwill always bundle its own copy regardless of the host’s config. Grep both configs forreactundershared. - Check the version ranges overlap. If host provides
18.3.1and the remote’srequiredVersionis^17, negotiation fails and the remote falls back to its own bundled copy.npm ls reactacross packages reveals the mismatch. - Check the import timing. A synchronous import of React in a Webpack entry before the share scope initializes throws
Shared module is not available for eager consumption; that means the fix is an async boundary, not a version bump.
The solution config
Declare react and react-dom as singleton shared dependencies on both the host and every remote, with a requiredVersion that all sides satisfy. singleton: true guarantees one instance for the whole page; requiredVersion drives negotiation; strictVersion: true turns an unsatisfiable range into a hard failure instead of a silent second copy.
Webpack 5
// webpack.config.js — applied IDENTICALLY to host and every remote // Webpack 5.80+, Node 20+
const { ModuleFederationPlugin } = require("webpack").container;
const deps = require("./package.json").dependencies;
module.exports = {
output: { publicPath: "auto" },
plugins: [
new ModuleFederationPlugin({
name: "remote_app", // "shell" on the host
filename: "remoteEntry.js",
exposes: { "./Widget": "./src/Widget.tsx" }, // omit on the host
shared: {
react: {
singleton: true, // exactly one React for the whole page
strictVersion: true, // fail loudly if ranges cannot reconcile
requiredVersion: deps.react,
},
"react-dom": {
singleton: true,
strictVersion: true,
requiredVersion: deps["react-dom"],
},
},
}),
],
};
If the remote imports React synchronously in its entry, introduce the async boundary so the share scope is initialized first:
// src/index.js — defer startup so the shared React resolves before any hook runs
import("./bootstrap");
// src/bootstrap.jsx
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(<App />);
Vite with @originjs/vite-plugin-federation
The array form (shared: ["react"]) already deduplicates, but use the object form to pin requiredVersion and document intent. Apply the same block to host and remote:
// vite.config.ts — host and remote both use this shared block // Vite 5.x, plugin 1.3.x
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
react(),
federation({
name: "remote_app", // "shell" on the host
filename: "remoteEntry.js",
exposes: { "./Widget": "./src/Widget.tsx" }, // remotes only
// remotes: { remote_app: "http://localhost:5001/assets/remoteEntry.js" }, // host only
shared: {
react: { requiredVersion: "^18.0.0" },
"react-dom": { requiredVersion: "^18.0.0" },
},
}),
],
build: {
target: "esnext", // federation runtime uses top-level await
cssCodeSplit: false,
},
});
# Build + serve the remote (plugin is build-only), then run the host against it
npm --prefix ./remote run build && npm --prefix ./remote run preview -- --port 5001 --strictPort
npm --prefix ./host run build && npm --prefix ./host run preview -- --port 5000 --strictPort
Eager vs lazy shared
eager: true bundles the shared React into the initial chunk, so it is available synchronously and you avoid the async-boundary requirement — at the cost of a larger first payload and the risk that two eager providers both ship React. Set eager: true on exactly one provider (usually the host) and leave remotes lazy:
// host only — provide React eagerly so remotes never need their own copy
shared: { react: { singleton: true, eager: true, requiredVersion: deps.react } }
Lazy sharing (eager: false, the default) keeps payloads minimal and is the right default; it requires the import("./bootstrap") indirection in Webpack so the share scope initializes before the first component renders. Do not set eager: true on multiple remotes — that reintroduces duplicate copies, the exact problem you are solving.
Verification
Rebuild and confirm a single React instance:
// DevTools, after the remote mounts — exactly one version key proves the singleton worked
console.log(Object.keys(window.__webpack_share_scopes__.default.react)); // ["18.3.1"]
console.log(window.React === undefined ? "no global" : "ok");
In the Network tab, react-dom should be requested exactly once across all origins. With strictVersion: true, an unsatisfiable range now fails the build with Unsatisfied version 18.2.0 ... from ... of shared singleton module react, which is the intended early signal — bump the offending range and rebuild. A passing render with working hooks and no console warning about multiple React copies is the success state.
Gotchas & edge cases
- Remote omits
reactfromshared. The single most common cause of duplicate React. Every remote that renders components must listreactandreact-domas shared, even if it “only renders one button”. react/jsx-runtimenot shared. With the automatic JSX transform, components importreact/jsx-runtime. If onlyreactis shared, the runtime can still pull a second copy. Add"react/jsx-runtime": { singleton: true, requiredVersion: deps.react }on every side.- Mismatched major versions.
singletoncannot reconcile React 17 with React 18 — the dispatchers are incompatible. Align majors first;strictVersionwill otherwise hard-fail (correctly). - Stateful peers beyond React. The same singleton treatment applies to
react-redux,react-router,@tanstack/react-query, and any library holding context — a duplicated provider silently breaks context propagation. Share them as singletons too. For why mixed CJS/ESM copies of these slip through, see Understanding ESM vs CommonJS in Modern Bundlers.
Related
- Module Federation and Micro-Frontend Architectures — the shared-scope runtime model this fix depends on.
- Webpack vs Vite Module Federation Comparison — how singleton negotiation differs between the two bundlers.
- Understanding ESM vs CommonJS in Modern Bundlers — why dual-format copies of React-adjacent packages evade deduplication.