Deduplicating Duplicate React Versions
Exact Symptoms
Two copies of React in one application surface as runtime failures, not install errors. The signatures are unmistakable:
Warning: 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
Alongside the hook warning you will see context that "does not propagate" (a useContext returns the default value even though a provider is mounted), a bundle that ships React twice, and npm ls react reporting more than one resolved version. The tell is always the same: more than one copy of React in the same app.
Root Cause Analysis
React relies on module-level singletons. Its internal "dispatcher" — the object that makes useState, useEffect, and every other hook work — lives in a single module instance. When your tree contains two physical copies of react (or react-dom), a component rendered by one copy calls hooks against the other copy's dispatcher, which is null from that perspective, producing the "Invalid hook call" error. The same split breaks createContext: a provider from copy A and a consumer from copy B reference different context objects, so the value never crosses.
Duplicate copies appear for predictable reasons. A library declares react as a regular dependency instead of a peerDependency, so the installer nests its own copy under node_modules/some-lib/node_modules/react. Or two dependencies request non-overlapping ranges (^17 and ^18), so the package manager keeps both. In monorepos, hoisting and nested node_modules compound the problem. Because the failure is rooted in how the resolver decides whether two requests can share one installed copy, Dependency Resolution Explained is the model to keep in mind throughout, and the underlying classification mistake is exactly what When to Use peerDependencies vs devDependencies addresses.
Resolution & Config Patch
Confirm the duplication first, then collapse the tree to one copy.
-
Prove there are duplicates. Run
npm ls react(andnpm ls react-dom). If it lists more than one version, or annotates entries asdeduped/invalid, you have the problem this page solves. -
Let the package manager deduplicate. When the requested ranges actually overlap, the manager can flatten them automatically:
npm dedupe # npm pnpm dedupe # pnpm yarn dedupe # Yarn 2+This rewrites the tree (and lockfile) to share a single copy wherever ranges permit. It cannot merge genuinely incompatible ranges — for those, continue below.
-
Force a single version with
overrides/resolutions. When a stale transitive range keeps a second copy alive, pin the whole tree to one version. For npm and pnpm, useoverridesinpackage.json:{ "overrides": { "react": "$react", "react-dom": "$react-dom" } }For Yarn, use
resolutions:{ "resolutions": { "react": "18.3.1", "react-dom": "18.3.1" } }The
$reactform reuses the version from your owndependenciesso there is one source of truth. This is the same mechanism described in Fixing npm ERESOLVE Peer Dependency Conflicts. -
Fix the real culprit: declare React as a peer in libraries. If you author a library, React must be a
peerDependency, never a regulardependency. A regular dependency tells installers to nest a private copy; a peer tells them to reuse the application's copy:{ "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "devDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" } }Keep React in
devDependenciesso the library still builds and tests in isolation, but out ofdependenciesso consumers never get a second copy. -
Deduplicate at the bundler too. Some duplicates are introduced by symlinked workspaces or path quirks the installer cannot flatten. Tell the bundler to resolve React to one path. For Vite/Rollup:
// vite.config.js export default { resolve: { dedupe: ["react", "react-dom"] }, };For webpack, alias both to a single resolved location:
// webpack.config.js const path = require("node:path"); module.exports = { resolve: { alias: { react: path.resolve("./node_modules/react"), "react-dom": path.resolve("./node_modules/react-dom"), }, }, };
After any change, reinstall and rebuild from a clean state:
rm -rf node_modules package-lock.json
npm install
CLI Validation & Debug Commands
# The primary check: how many versions, and where?
npm ls react
npm ls react-dom
# Trace why a given copy exists
npm explain react
# Confirm a clean, deterministic install keeps it single
rm -rf node_modules && npm ci
A fixed tree shows exactly one react and one react-dom under npm ls, with no invalid markers. If you bundle, grep the output stats or source map for react.production.min.js appearing twice — one occurrence means the dedupe held through the build, not just the install.
Prevention & CI Guardrails
- Gate on a single copy. Add a CI step that runs
npm ls react react-domand fails if more than one version resolves, catching regressions on every PR. - Lint library manifests. Enforce that published packages declare React in
peerDependenciesand never independencies, the most common source of nested copies. - Keep overrides reviewed. Treat each
overrides/resolutionsentry as a temporary patch with an owner; remove it once upstream ranges widen. - Pin the package manager and Node version. A consistent
"packageManager"field plus a committed lockfile means everyone resolves the same single copy, so duplicates do not reappear locally. - Run
npm ci, notnpm install, in CI. Installing from the lockfile prevents drift that quietly reintroduces a second version.
Why two React copies break so badly
Two copies of React in one tree cause bugs that seem impossible because each copy is individually correct. React's hooks, context, and reconciler all rely on module-level state that must be shared across the whole component tree — the hook dispatcher, the current context value, the fiber registry. When a component from one React copy renders a provider and a component from the other copy consumes it, they are reading different module-level state, so the context appears empty, hooks throw 'invalid hook call', and instanceof checks on React elements fail.
The reason this happens is a resolution outcome: two packages depend on React through ranges that no single version satisfies, so the resolver installs two copies, or a package bundles its own React instead of treating it as a peer. Both produce two module identities where the ecosystem assumes one. Understanding that the breakage comes from duplicated module-level state — not from a version incompatibility per se — is what points at the fix: collapse the copies to one so there is a single dispatcher, a single context, a single reconciler shared across every component.
Forcing a single copy and keeping it that way
Deduplication collapses compatible copies, and an override forces a single version when the ranges do not naturally converge. npm dedupe/pnpm dedupe re-examine the tree and hoist React to a shared version where the ranges permit; when they do not, an override pins one version across the graph. The other half of the fix is ensuring libraries treat React as a peer dependency rather than a direct one, so they use the consumer's single copy instead of bundling their own.
{ "overrides": { "react": "18.3.1", "react-dom": "18.3.1" } }
After forcing a single version, verify with npm ls react that every path resolves to one copy — multiple versions in the output mean the duplication remains. Keeping it that way means auditing after dependency changes: a new library that declares React as a direct dependency, or pins an incompatible range, can reintroduce a second copy. This is why libraries in the React ecosystem should declare React as a peer with a wide range, and why an application should periodically confirm with npm ls react that its whole tree resolves to the single copy the framework requires. A single React instance is not something you set once; it is an invariant you verify holds after every graph change.
Diagnosing which dependency pulls the second copy
Before forcing a version, find out why the second copy exists, because the durable fix depends on the cause. npm ls react prints every path React appears on and the version each resolved to, so two versions in the output show exactly which dependencies requested incompatible ranges. A library that pins an exact React version, or declares React as a direct dependency instead of a peer, is the usual culprit, and the output names it.
# Show every React copy and what pulled it in
npm ls react
pnpm why react
With the culprit identified, the right fix follows: if a library declared React as a direct dependency, the durable fix is for that library to make it a peer (file an issue, or patch it locally); if two libraries pinned incompatible ranges, an override forces one version. Reaching for an override without first reading npm ls risks masking a deeper problem — a library that genuinely needs a different React major, for instance, where forcing one version breaks it. Diagnosing first, then choosing between a peer-dependency fix and an override, is what turns a duplicate-React problem from a recurring annoyance into a resolved, understood state where the single-copy invariant holds for a known reason.
Frequently Asked Questions
Why does only one copy of React work when two are installed fine?
React stores its hook dispatcher and context registry as module-level singletons. Components must call hooks and read context from the same module instance that rendered them. Two physical copies mean two dispatchers, so a hook call resolves against a null dispatcher and context lookups miss the matching provider.
Is npm dedupe enough on its own?
Only when the conflicting ranges already overlap. npm dedupe flattens copies it is allowed to merge, but it will not combine genuinely incompatible ranges like ^17 and ^18. For those, align the versions or pin one with overrides/resolutions.
Should a component library list React in dependencies?
No. Listing React as a regular dependency instructs installers to nest a private copy, which is the leading cause of duplicate-React errors. Declare it in peerDependencies (so consumers supply it) and in devDependencies (so your own builds and tests still run).
Why do I still get duplicates after overrides in a monorepo?
Workspace symlinks can present React through more than one filesystem path even when only one version is installed. Add bundler deduplication (resolve.dedupe or a webpack alias) so the build collapses those paths to a single module.
Why does having two React copies break hooks and context?
React's hooks, context, and reconciler rely on module-level state that must be shared across the whole tree. Two copies mean two separate states, so a provider from one copy and a consumer from the other read different context, hooks throw 'invalid hook call', and element checks fail.
How do I force a single React version?
Run npm dedupe/pnpm dedupe to collapse compatible copies, or add an overrides entry pinning react and react-dom to one version when ranges don't converge. Verify with npm ls react that every path resolves to one copy, and ensure libraries declare React as a peer, not a direct dependency.
How do I find what's causing a duplicate React copy?
Run npm ls react or pnpm why react to see every path React appears on and the versions requested. Two versions reveal which dependencies pinned incompatible ranges or declared React as a direct dependency instead of a peer — the culprit to fix or override.
Does this apply to other frameworks besides React?
Yes — any package with module-level singleton state (Vue's reactivity, a shared store, a library whose objects are compared by identity) breaks the same way when duplicated. The fix is identical: declare it as a peer, dedupe or override to a single copy, and verify with npm ls.
Why does pnpm surface duplicate React more readily than npm?
pnpm's strict, content-addressed store isolates packages unless explicitly hoisted, so an incompatible second copy resolves as a distinct instance rather than being silently flattened. The duplication surfaces earlier and more loudly, which makes it easier to catch before it breaks context at runtime.
Related
- When to Use peerDependencies vs devDependencies — declaring React as a peer is the permanent fix for duplicate copies.
- Fixing npm ERESOLVE Peer Dependency Conflicts — the same
overridesmechanism, applied to install-time peer errors. - Lockfile Management Strategies — keeping a committed lockfile so a single resolved copy stays single.
- Understanding package.json Fields — how
dependencies,peerDependencies, andoverridesshape what gets installed.