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.
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.
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.