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.
The reason this breaks so violently is that React’s hook machinery is not a pure function library — it is stateful module-level singleton state. Every call to useState or useEffect reads a mutable field, ReactCurrentDispatcher.current, that the renderer sets during a render pass. When your host bundle imports react from its own copy and a remote component was compiled against a second copy, the component’s hooks read a dispatcher that was never populated, because the render was driven by the other React. The dispatcher is null, useState dereferences it, and you get either the friendly Invalid hook call or the raw Cannot read properties of null (reading 'useState') depending on how far the call got. No amount of aligning versions in package.json fixes this if two physical copies still reach the browser: the module identity, not the version string, is what matters at runtime.
This problem lives at the boundary between the build graph and the runtime module registry. During the build, each remote is compiled independently and has no way to know which React the host will load. Module Federation closes that gap with a shared scope: a runtime map, keyed by package name and version, that every container consults before instantiating its own copy of a dependency. Marking react as shared moves it out of the remote’s static bundle and into a lazy chunk that is only fetched if the scope does not already hold a satisfying version. singleton: true narrows the scope further, forbidding more than one live instance regardless of how many versions were offered. Getting the flags right is the difference between one 140 KB React on the page and one per micro-frontend, each with its own broken hook dispatcher.
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. The tell is that the error fires only when a remote’s component renders, while the host’s own components work — if everything is broken, suspect a genuine hook-rules violation instead. Confirm by noting which bundle the failing component came from in the stack trace; a path under the remote’sremoteEntryor anhttp://localhost:5001origin points at a duplicate, not at your call site. - 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. The share scope is the authoritative source of truth here: if it lists two version keys, the scope itself is holding two copies and no runtime dedup will save you; if it lists one key but the component still fails, the remote never registered React as shared and is loading a copy that bypasses the scope entirely — which is exactly what step 3 checks. - 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. This is asymmetric and worth internalizing: the host declaring React shared does not pull the remote into the scope. Sharing is opt-in per container, so a single remote author who forgot thesharedblock reintroduces the duplicate for the whole page. Confirm the fix by rebuilding that remote and re-running the step-2 console check — the extra version key should disappear. - 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. The subtle failure mode is a range that looks compatible but is not: a remote pinned to~18.2.0cannot consume a host’s18.3.1, so the scope keeps both. Widen the remote’srequiredVersionto^18.0.0so any 18.x host satisfies it, and prefer deriving the range frompackage.jsonrather than hand-typing a narrower one. - 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 share scope is populated asynchronously during container startup, so any module in the synchronous entry graph that touches React runs before the scope exists. Theimport("./bootstrap")indirection defers your entire component tree past that initialization tick. Confirm the boundary is correct by checking that nothing insrc/index.jsimports React or a React component directly — only the dynamicimport()should appear there.
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.
eager belongs to a single provider.Webpack 5
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
How version negotiation works under the hood
When a container boots it calls __webpack_init_sharing__("default"), which creates (or reuses) the shared scope object and registers every one of that container’s shared modules as a lazy factory keyed by name and version — for example scope.react["18.3.1"] = { get: () => import(...), loaded: 0 }. A remote’s container does the same against the same scope object when the host initializes it. At the moment a shared module is first requested, Module Federation runs its resolution algorithm: collect all registered versions of react, filter to those satisfying the consumer’s requiredVersion semver range, and pick the highest. For a singleton it then discards the losers and pins the winning factory so every subsequent request — from any container — returns the identical instance. This is why the “highest satisfying version wins” rule is not a heuristic but the literal comparator: 18.3.1 beats 18.2.0 because semver.gt says so, and both sides then close over the same module namespace object.
The consequence of singleton: true is that this pinning is enforced across containers rather than within one. Without it, each container is free to resolve to a different satisfying version, so a host on 18.3.1 and a remote requiring ~18.2.0 would each keep their own — two dispatchers, the original bug. strictVersion: true changes only the failure branch: when no registered version satisfies a consumer’s range, the default behavior is to warn and load whatever is closest (or the consumer’s fallback), whereas strict mode throws at that point. Treat strict mode as a build-time assertion that your versions actually reconcile; it converts a runtime hook crash into a loud, greppable error during the load.
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”. The symptom is theInvalid hook callfiring only inside that one remote; the root cause is that its bundle statically inlined React and never consulted the scope; the fix is adding thesharedblock and rebuilding; you confirm it by watching the extra version key vanish fromwindow.__webpack_share_scopes__.default.react. 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. This one is insidious becausereactitself can be correctly deduplicated whilejsx-runtimequietly is not — the second copy is smaller so it evades a casual bundle-size glance, but it re-exports the same internals and can still fork the dispatcher whenjsx()creates elements. Confirm by grepping the built chunks forreact/jsx-runtimeand checking the scope holds a single key for it as well.- Mismatched major versions.
singletoncannot reconcile React 17 with React 18 — the dispatchers are incompatible. Align majors first;strictVersionwill otherwise hard-fail (correctly). There is no config that safely lets a 17 remote and an 18 host share one instance: the internalReactSharedInternalsshape changed between majors, so even if negotiation picked one, the other’s compiled code would read fields that no longer exist. The correct fix is a coordinated upgrade, not a wider range; if you must ship them separately during a migration, isolate the old remote behind an iframe rather than sharing across the version gap. - 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. The failure here is quieter than a duplicate React: there is no thrown error, just auseContextthat reads the default value because theProviderabove it came from a different copy of the library’screateContext. A router that “renders nothing” or a Redux store that “is always empty in the remote” is usually this. Share them as singletons and confirm the provider and consumer resolve to the same module in the scope. For why mixed CJS/ESM copies of these slip through, see Understanding ESM vs CommonJS in Modern Bundlers.
When singleton sharing is the wrong tool
Singleton sharing assumes every micro-frontend can agree on one React version at the same instant across a single deploy. That assumption is reasonable inside one team’s build but strained across independently deployed remotes owned by different teams on different release cadences. If remote A upgrades to React 19 on Monday and the host is still on 18 until its next release, strictVersion will fail A’s load and singleton without strict will silently break A’s hooks — neither is a good outcome. When independent deployability genuinely outranks bundle size, the honest alternative is full runtime isolation: render each remote in its own root with its own React and communicate through DOM events or a shared, framework-agnostic state bus, accepting the duplicated runtime cost. Singleton sharing buys you shared context and a smaller payload in exchange for a coordination constraint; if you cannot pay that constraint, do not pretend the flags will hide it.
Performance considerations
The shared React is fetched as a separate chunk the first time any container needs it, which adds one request that a fully-inlined build would have folded into the entry. In practice this is a net win: a single cacheable react chunk shared across five remotes replaces five inlined copies, and the browser caches it once. The cost to watch is the async-boundary tax under lazy sharing — the import("./bootstrap") indirection means the first paint waits for the share scope to initialize and the React chunk to arrive over the network. On a warm cache this is negligible; on a cold first load it adds a round trip. If that first-load latency matters more than the eager provider’s larger initial bundle, set eager: true on the host so React is present synchronously in the host entry and remotes consume it without their own boundary. Measure the tradeoff with the Network waterfall rather than guessing: the eager host pays bytes up front, the lazy host pays a request in the critical path.
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.