Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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.

From duplicate React to a single copy A flow showing two nested React copies collapsing into one hoisted copy that both consumers share. Before: two copies app uses react@18.3.1 lib-a nests react@17.0.2 root react react@18.3.1 dedupe + override After: one copy single hoisted react@18.3.1 shared by app + lib-a app lib-a (peer) One dispatcher, one context: hooks and providers work again.
Collapsing nested copies into a single hoisted React restores the shared dispatcher and context.

Resolution & Config Patch

Confirm the duplication first, then collapse the tree to one copy.

  1. Prove there are duplicates. Run npm ls react (and npm ls react-dom). If it lists more than one version, or annotates entries as deduped/invalid, you have the problem this page solves.

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

  3. 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, use overrides in package.json:

    {
      "overrides": {
        "react": "$react",
        "react-dom": "$react-dom"
      }
    }

    For Yarn, use resolutions:

    {
      "resolutions": {
        "react": "18.3.1",
        "react-dom": "18.3.1"
      }
    }

    The $react form reuses the version from your own dependencies so there is one source of truth. This is the same mechanism described in Fixing npm ERESOLVE Peer Dependency Conflicts.

  4. Fix the real culprit: declare React as a peer in libraries. If you author a library, React must be a peerDependency, never a regular dependency. 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 devDependencies so the library still builds and tests in isolation, but out of dependencies so consumers never get a second copy.

  5. 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-dom and fails if more than one version resolves, catching regressions on every PR.
  • Lint library manifests. Enforce that published packages declare React in peerDependencies and never in dependencies, the most common source of nested copies.
  • Keep overrides reviewed. Treat each overrides/resolutions entry 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, not npm 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

Dependency Resolution Explained