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.

Duplicate React versus a negotiated singleton React instance The top path shows two React copies causing Invalid hook call; the bottom path shows singleton negotiation resolving to one shared React instance. One React, not two Without singleton Host react 18.3.1 Remote react 18.2.0 (own copy) Invalid hook call With singleton + requiredVersion Host react 18.3.1, ^18 Remote react requires ^18 shared scope -> 18.3.1 (one) Highest satisfying version wins; both sides import the same instance strictVersion: true fails the build instead of silently loading a second copy
Figure: without a singleton each side keeps its own React; with singleton negotiation the scope resolves one shared instance.

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
A deliberate version mismatch reproduces the bug The repro pins the host to react 18.3.1 and the remote to 18.2.0 so version negotiation is forced; npm ls in each package showing different versions confirms the setup before applying the singleton fix. host: react 18.3.1pinned exact remote: react 18.2.0different patch → negotiation Force the conflict first — then confirm the fix removes it
Figure: the repro pins two different React patches on purpose so the negotiation path is exercised.

Diagnosis workflow

Work through these in order; each step narrows where the second React is coming from.

Five-step duplicate-React diagnosis Read the exact error, count React instances in the share scope, confirm both sides declare react as shared, check that version ranges overlap, then check import timing for an eager-consumption error. 1 read errortwo copies? 2 count in scopeversion keys 3 both shared?grep configs 4 ranges overlap?npm ls 5 import timingeager?
Figure: each step rules out one source of the second React — config, version range, or import timing.
  1. Read the exact error. Invalid hook call with 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’s remoteEntry or an http://localhost:5001 origin points at a duplicate, not at your call site.
  2. Count React instances at runtime. In the browser console, before any fix, check whether the remote brought its own copy:
    // 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 scope
    For Vite federation, search the Network tab for more than one react or react-dom chunk 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.
  3. Confirm both sides declare react as shared. A remote that omits react from shared will always bundle its own copy regardless of the host’s config. Grep both configs for react under shared. 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 the shared block 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.
  4. Check the version ranges overlap. If host provides 18.3.1 and the remote’s requiredVersion is ^17, negotiation fails and the remote falls back to its own bundled copy. npm ls react across packages reveals the mismatch. The subtle failure mode is a range that looks compatible but is not: a remote pinned to ~18.2.0 cannot consume a host’s 18.3.1, so the scope keeps both. Widen the remote’s requiredVersion to ^18.0.0 so any 18.x host satisfies it, and prefer deriving the range from package.json rather than hand-typing a narrower one.
  5. 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. The import("./bootstrap") indirection defers your entire component tree past that initialization tick. Confirm the boundary is correct by checking that nothing in src/index.js imports React or a React component directly — only the dynamic import() 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.

The three shared-config flags and eager placement singleton true guarantees one instance for the page, strictVersion true fails loudly on an unsatisfiable range, requiredVersion drives negotiation, and eager true must be set on exactly one provider — usually the host. singleton: trueone instance for the page strictVersion: truefail loudly on mismatch requiredVersiondrives negotiation eager: true on exactly ONE provider (the host)two eager providers reintroduce duplicate React
Figure: three flags on every side, plus one rule — 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:

Two checks that the singleton took The share scope must list exactly one react version key, and react-dom must be requested exactly once across all origins in the Network tab; with strictVersion an unsatisfiable range fails the build instead. Object.keys(share scope react) ["18.3.1"] — one key Network: react-dom requests exactly once
Figure: one version key in the scope and one network request — either failing means a second React survived.
// 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

Four singleton-React edge cases A remote that omits react from shared always bundles its own; react/jsx-runtime must also be shared or the JSX transform pulls a second copy; major version mismatches cannot reconcile; and stateful peers like react-redux and react-router need the same singleton treatment. remote omits react it always bundles its own — every remote rendering components must share react. jsx-runtime not shared the auto JSX transform pulls a second copy — share react/jsx-runtime too. major mismatch can't reconcile 17 vs 18 dispatchers are incompatible — align majors; strictVersion hard-fails. stateful peers too react-redux, react-router, react-query hold context — share them as singletons.
Figure: two "you forgot to share something" traps and two "sharing react alone isn't enough" traps.
  • Remote omits react from shared. The single most common cause of duplicate React. Every remote that renders components must list react and react-dom as shared, even if it “only renders one button”. The symptom is the Invalid hook call firing only inside that one remote; the root cause is that its bundle statically inlined React and never consulted the scope; the fix is adding the shared block and rebuilding; you confirm it by watching the extra version key vanish from window.__webpack_share_scopes__.default.react.
  • react/jsx-runtime not shared. With the automatic JSX transform, components import react/jsx-runtime. If only react is 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 because react itself can be correctly deduplicated while jsx-runtime quietly 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 when jsx() creates elements. Confirm by grepping the built chunks for react/jsx-runtime and checking the scope holds a single key for it as well.
  • Mismatched major versions. singleton cannot reconcile React 17 with React 18 — the dispatchers are incompatible. Align majors first; strictVersion will otherwise hard-fail (correctly). There is no config that safely lets a 17 remote and an 18 host share one instance: the internal ReactSharedInternals shape 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 a useContext that reads the default value because the Provider above it came from a different copy of the library’s createContext. 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.