Fixing Shared Dependency Version Conflicts in Module Federation
The federated region renders once in development and then, in a deployed environment, produces this:
Uncaught Error: Invalid hook call. Hooks can only be called inside of the body
of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
at resolveDispatcher (react.production.min.js:18:283)
at useState (react.production.min.js:20:198)
at ReportPanel (reports_ReportPanel.js:44:19)
Reason three, essentially always. The host’s own components work; only the remote’s throw. Sometimes the failure is quieter still — no error at all, just a context provider in the host whose values the remote cannot see, or a router whose navigation the remote’s links do not trigger.
Root cause: two copies of module-level state
The shared scope described in module federation and cross-application chunk sharing is a runtime registry: each build registers the shared packages it can provide, along with their versions, and the runtime picks one instance to satisfy everyone.
That negotiation only happens for packages both sides declared as shared. When it does not happen — or when it happens but resolves to two instances because neither was marked a singleton — each build ends up importing its own copy. For a stateless utility, two copies are merely wasteful. For any package holding module-level state, two copies are a functional break:
- React stores the current hook dispatcher on the instance. A second copy has a null dispatcher.
- React context identity is per-instance. A provider from copy A is invisible to a consumer from copy B.
- Routers keep history and match state in module scope. Two copies means two independent notions of the current URL.
- Styling runtimes register a stylesheet cache; two caches produce duplicate or missing style injection.
Note the detail in the right-hand case: both instances are the same version. Version alignment alone does not prevent duplication — the package has to be declared shared on both sides, or the copy is simply bundled and never negotiated.
Diagnosis: count the instances
Before changing configuration, establish which of the two failure shapes you have — an undeclared package, or a declared one that resolved to two instances.
// paste in the console of the running host
// Webpack 5 exposes the federated share scope as a global registry.
const scope = __webpack_share_scopes__ && __webpack_share_scopes__.default;
for (const [name, versions] of Object.entries(scope || {})) {
const loaded = Object.entries(versions).filter(([, v]) => v.loaded);
if (loaded.length > 1) {
console.warn(`${name}: ${loaded.length} instances →`, loaded.map(([v]) => v));
}
}If the suspect package shows two loaded entries, it is declared on both sides but not resolving to one — a missing singleton flag or incompatible ranges. If it shows one entry (or none) while the failure persists, one build is bypassing the shared scope entirely, and the copy is inside its own chunks. Confirm the second case by searching the remote’s built output for the package’s source, using the attribution approach in attributing bundle bloat with source maps.
The fix: singletons with aligned ranges, declared on every side
// webpack.config.js — the SAME shared block in the host and in every remote
const { ModuleFederationPlugin } = require('webpack').container;
const { dependencies } = require('./package.json');
// One source of truth, imported by every participating build.
const shared = {
react: {
singleton: true, // exactly one instance, always
requiredVersion: dependencies.react, // from package.json, never hand-written
strictVersion: process.env.NODE_ENV !== 'production', // throw in dev/CI
},
'react-dom': {
singleton: true,
requiredVersion: dependencies['react-dom'],
strictVersion: process.env.NODE_ENV !== 'production',
},
'react-router-dom': { singleton: true, requiredVersion: dependencies['react-router-dom'] },
'@acme/design-system': { singleton: true, requiredVersion: dependencies['@acme/design-system'] },
};
<svg viewBox="16 40 688 140" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three configuration decisions and the failure each one prevents" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;">
<title>Three Settings, Three Failures Prevented</title>
<desc>Singleton prevents duplication, a version read from the manifest prevents drift, and strict checking outside production turns a silent warning into a build failure.</desc>
<rect x="16" y="40" width="688" height="140" fill="#FAF7F0"/>
<rect x="24" y="48" width="204" height="80" rx="6" fill="none" stroke="#2a9e5e" stroke-width="1.5"/>
<text x="126" y="72" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11" font-weight="600">singleton: true</text>
<text x="126" y="94" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">prevents: two instances</text>
<text x="126" y="112" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">of module-level state</text>
<rect x="258" y="48" width="204" height="80" rx="6" fill="none" stroke="#2a9e5e" stroke-width="1.5"/>
<text x="360" y="72" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11" font-weight="600">version from the manifest</text>
<text x="360" y="94" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">prevents: a declared range</text>
<text x="360" y="112" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">drifting from what is installed</text>
<rect x="492" y="48" width="204" height="80" rx="6" fill="none" stroke="#2a9e5e" stroke-width="1.5"/>
<text x="594" y="72" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11" font-weight="600">strict outside production</text>
<text x="594" y="94" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">prevents: an incompatible</text>
<text x="594" y="112" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="10">remote reaching users</text>
<text x="360" y="168" text-anchor="middle" fill="currentColor" font-family="sans-serif" font-size="11">All three belong in one shared block imported by every participating build</text>
</svg>
module.exports = {
plugins: [new ModuleFederationPlugin({ name: 'shell', remotes: { /* … */ }, shared })],
};Three decisions in that block do the work. singleton: true on every stateful package removes the possibility of two instances. Reading requiredVersion from package.json keeps the declared range and the installed version from drifting apart, which is the most common cause of a range that no instance can satisfy. And strictVersion turned on outside production converts a silent runtime warning into a thrown error, so an incompatible remote fails in CI rather than in front of users.
The equivalent in Vite’s federation plugin is terser but has the same semantics:
// vite.config.js — Vite 5+
import federation from '@originjs/vite-plugin-federation';
export default {
plugins: [
federation({
name: 'shell',
remotes: { reports: `${process.env.REPORTS_URL}/assets/remoteEntry.js` },
shared: {
react: { singleton: true, requiredVersion: '^18.3.0' },
'react-dom': { singleton: true, requiredVersion: '^18.3.0' },
},
}),
],
};Step-by-step verification
-
Re-run the instance count. After rebuilding both sides, the share scope must report exactly one loaded entry per singleton package.
-
Verify context crosses the boundary. Render a host-provided context consumer inside the remote’s tree and confirm it reads the host’s value, not the default.
-
Verify the remote’s chunks do not contain the package. Search the remote’s built output for a distinctive string from the shared package’s source. A hit means the sharing declaration is not taking effect for that build.
-
Test an intentional mismatch. Temporarily declare an incompatible range in the remote and confirm the build fails or throws in a non-production environment rather than warning quietly.
-
Test with the remote deployed independently. Rebuild and redeploy only the remote, then reload the host without rebuilding it. The negotiation must still resolve to one instance.
-
Check the router and styling runtime too. Framework duplication is the loudest case, but a duplicated router or style cache produces subtler bugs that no error message names.
Edge cases and gotchas
A transitive dependency requiring a different major. A third-party component inside the remote may declare its own peer requirement. Because the shared scope resolves by package name, that requirement participates in the same negotiation, and an old peer range can force a resolution neither team intended.
Development-only duplication. Vite’s dev server serves unbundled modules and resolves shared packages differently from a production build, so federation can appear healthy in development and duplicate in production. Always verify against a built artifact.
Eager sharing masking the problem. Marking a package eager: true puts it in the initial chunk of every build, which can make the symptom disappear while leaving both copies present. It trades a visible break for silent duplication and a larger payload.
Version ranges that overlap on paper only. A host requiring ^18.3.0 and a remote requiring ~18.2.0 have no common satisfying version. Both sides pass their own checks, and the runtime picks one, warns, and continues — until an API that changed between the two is called.
FAQ
Why does an invalid hook call only happen inside the federated component?
Because the remote’s component is executing against a different React instance than the one that rendered the host tree. Hooks are stored on the instance that owns the current renderer; when the remote imports its own copy, its hook calls look up a dispatcher that is null because that copy never started rendering. The host’s own components keep working, which is why the failure looks specific to the federated region rather than global.
Does singleton: true guarantee only one copy loads?
It guarantees the runtime will use only one, provided both sides declare the package as shared and singleton. It does not protect against a build that never declared the package at all, in which case that build’s copy is bundled inside its own chunks and the shared scope is bypassed entirely. That is the case where the warning never appears and the duplicate is invisible in the share scope but obvious in the chunk contents.
What should happen when a remote requires a major version the host cannot provide?
The integration should fail visibly, not silently degrade. With strict version checking the runtime throws when the requirement cannot be satisfied, which surfaces the incompatibility during integration testing instead of producing subtle misbehaviour in production. The underlying fix is organisational: a shared framework major version is a contract between teams, and upgrading it requires a coordinated release rather than an independent one.
Related
- Module Federation and Cross-Application Chunk Sharing — the parent guide covering hosts, remotes, and the shared scope
- Debugging Remote Entry Loading Failures — when the container never loads at all
- Finding Duplicate Dependencies in a Bundle — confirming a second copy is really in the output
- Choosing splitChunks Cache Groups for Shared Modules — the single-build equivalent of sharing decisions