Resolving Version Conflicts in Shared React Libraries #
Your remote loads, then the page throws Invalid hook call. Hooks can only be called inside the body of a function component — because the host and the remote each pulled in their own copy of React, and the second copy has no idea about the first one’s dispatcher.
This is the single most common runtime failure in Module Federation. React keeps its hooks dispatcher, context registry, and scheduler on a module-level singleton. The moment two React builds exist in one page, hooks created against one instance run against the other’s internal state, and the invariant check fires. The fix is to make every federated app agree on exactly one React instance at runtime, and to make the build fail loudly when they can’t.
This guide walks through the precise shared configuration, how version negotiation actually picks a winner, the fallbacks worth keeping, and how to verify you really have a single instance.
Prerequisites #
Before changing config, confirm the following are true across the host and every remote:
- Webpack 5.x (
ModuleFederationPluginships inwebpack.container) or Vite 5.x with@originjs/vite-plugin-federation@^1.3. - React and react-dom declared as direct dependencies in each app’s
package.json— not just hoisted transitively. - You know the exact resolved versions. Run
npm ls react react-dom(orpnpm why react) in each repo and write them down; you cannot negotiate versions you haven’t measured. - A host that boots through an async boundary (a
bootstrap.jsimported byindex.js), so the shared scope is populated before any shared module is consumed. This async boundary is mandatory forsingletonto work.
Step 1: Mark React as a shared singleton #
In both the host and every remote, list react and react-dom in the shared object with singleton: true. This tells the federation runtime that only one copy may ever be loaded into the page, regardless of how many apps request it.
// webpack.config.js — host AND every remote use the same shared block
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host_app',
remotes: {
checkout: 'checkout@https://cdn.example.com/checkout/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
};
With singleton: true and nothing else, the runtime loads the highest compatible version it finds across all apps and routes every import 'react' to that one instance. The same shared block belongs in the remote’s config too — a singleton is only honored if every participant agrees to it. The mechanics of how a shared scope is populated are covered in depth in the guide on sharing singletons across Webpack and Vite remotes.
Step 2: Pin the accepted range with requiredVersion #
singleton: true alone will happily pair a host on React 18 with a remote on React 17 — it just warns. Add requiredVersion so each app declares the range it is actually compatible with. Read it from package.json so it never drifts from what you installed.
shared: {
react: {
singleton: true,
requiredVersion: deps.react, // e.g. "^18.2.0"
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
},
},
When the runtime resolves the shared scope, it checks the chosen singleton against each app’s requiredVersion. If the negotiated version satisfies every range, you get one instance and no warnings. If a remote’s range excludes the host’s version, you get a console warning at load time — useful, but it does not stop the broken render.
Step 3: Make incompatible versions fail loudly with strictVersion #
A warning is easy to miss in a busy console, and a silently mismatched React still corrupts the fiber tree. Set strictVersion: true so the runtime throws instead of falling back when no version satisfies the requirement. For a core framework like React, a hard failure at boot is far cheaper than an Invalid hook call three screens deep.
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
strictVersion: true, // throw, don't warn-and-continue
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
strictVersion: true,
},
},
strictVersion only has teeth when paired with requiredVersion; it governs what happens when the available version is outside the declared range. Keep it on in CI and staging so a mismatched remote breaks the pipeline rather than production.
Step 4: Configure the Vite side identically #
If any participant is a Vite app, mirror the same intent with @originjs/vite-plugin-federation. The plugin supports singleton and requiredVersion; it does not implement strictVersion, so keep the host (usually Webpack) authoritative on hard failures.
// vite.config.ts — Vite remote
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
federation({
name: 'reports',
filename: 'remoteEntry.js',
exposes: { './ReportsPage': './src/ReportsPage' },
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
}),
],
build: { target: 'esnext' },
optimizeDeps: { exclude: ['react', 'react-dom'] },
});
The optimizeDeps.exclude line matters: without it Vite’s dev-time pre-bundler can create a second React copy that sidesteps the federation-managed singleton. Cross-bundler singleton sharing has its own sharp edges, all collected in the guide on sharing singletons across Webpack and Vite remotes.
Step 5: Provide a deliberate fallback version #
The shared scope can only serve a version that someone actually shipped. If the host lazy-loads a remote before its own React is registered, define which app owns the fallback by setting version explicitly and letting the host load eagerly behind its async boundary.
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
strictVersion: true,
version: deps.react.replace(/^[\^~]/, ''), // concrete fallback, e.g. "18.3.1"
},
},
The rule of thumb: the host should be the highest compatible React in the system, so the negotiated singleton always satisfies older remotes’ ranges. Aligning a single deduplicated React across the whole topology is exactly the goal described in configuring shared singletons to deduplicate React.
Verification: prove there is exactly one React instance #
Configuration is not done until you can demonstrate a single instance at runtime. Three checks, fastest first.
1. Console probe. Load the page with at least one remote mounted, then in DevTools:
// Both must print the same string AND be the same object
console.log(window.__mf_react_check__ = (window.__mf_react_check__ || []));
// In the host bundle and a remote bundle, push their require('react'):
// host: window.__mf_react_check__.push(require('react'));
// remote: window.__mf_react_check__.push(require('react'));
const [a, b] = window.__mf_react_check__;
console.log('same instance:', a === b, a.version, b.version);
If a === b is false, you still have two copies regardless of matching version strings.
2. Bundle assertion in CI. Use webpack-bundle-analyzer (or rollup-plugin-visualizer for Vite) and fail the build if more than one node_modules/react/ module appears in emitted chunks.
3. Runtime test. An end-to-end test that mounts a host component and a remote component on the same page, then asserts no Invalid hook call and that a shared context value set by the host is readable by the remote:
// playwright + a test route that renders host + remote together
import { test, expect } from '@playwright/test';
test('single React instance across host and remote', async ({ page }) => {
const errors: string[] = [];
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
await page.goto('/integration-test');
await expect(page.getByTestId('remote-mounted')).toBeVisible();
expect(errors.filter((e) => e.includes('Invalid hook call'))).toHaveLength(0);
});
Troubleshooting #
requiredVersion mismatch warning: “Unsatisfied version X from app of shared singleton module react (expected Y)”
The negotiated singleton (X) falls outside an app’s declared range (Y). The runtime kept going and used X anyway. Diagnose with npm ls react in the complaining app — its requiredVersion is stricter than the version the host shipped. Fix by widening that app’s range to include the host version (e.g. ^18.0.0) or by upgrading the lagging app so its installed version matches. The warning is benign only if both versions are the same React major.
strictVersion throws: “Unsatisfied version … strictVersion is true”
This is the failure working as intended — a remote declared a range the available React cannot satisfy. Do not “fix” it by removing strictVersion; that just hides a real incompatibility. Instead align versions: bump the offending remote to the host’s React, or, if you genuinely must run a mismatched major, drop that remote out of the shared singleton and load it in isolation (see the multiple-majors case below).
Two different React majors must coexist (e.g. a legacy 17 remote in an 18 host)
You cannot share one singleton across a major boundary — the internals are incompatible. Remove the legacy remote from the shared react scope so it bundles its own copy, and render it inside a hard isolation boundary such as an iframe or a custom element with its own root. The mainline apps keep sharing one modern React; the legacy island ships a private React 17. Plan to retire the island rather than keep two majors alive indefinitely.
react-dom not aligned with react
React and react-dom are a matched pair — sharing one as a singleton but letting the other duplicate produces the same hook failures, often with a confusing react-dom.development.js stack. Always configure react, react-dom, and (if you use them) react/jsx-runtime and scheduler with the identical singleton/requiredVersion/strictVersion block. Verify both with the console probe in the verification section; a single mismatched member of the pair is enough to break hooks.