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.

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

Diagnosis workflow

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

  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.
  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.
  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.
  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.
  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 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 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”.
  • 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.
  • Mismatched major versions. singleton cannot reconcile React 17 with React 18 — the dispatchers are incompatible. Align majors first; strictVersion will 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.