The two-Reacts bug: when packages aren't singletons

"Invalid hook call. Hooks can only be called inside of the body of a function component." You read the error, you open the component, and the hook is exactly where it's supposed to be, top level of a function component, no condition, no loop, no class in sight. The docs page for this warning lists three possible causes. The first is mismatched react and react-dom versions, the second is breaking the Rules of Hooks, and the third is "more than one copy of React in the same app," and nearly everyo
"Invalid hook call. Hooks can only be called inside of the body of a function component."
You read the error, you open the component, and the hook is exactly where it's supposed to be, top level of a function component, no condition, no loop, no class in sight. The docs page for this warning lists three possible causes. The first is mismatched react and react-dom versions, the second is breaking the Rules of Hooks, and the third is "more than one copy of React in the same app," and nearly everyone reads the first two and skips the third, because who has two copies of React?
You, probably. It's the most common cause of that error in any setup involving a component library, a monorepo, or npm link, and it has a nastier sibling that doesn't throw at all: a context Provider that sets a value while every consumer under it calmly reads the default. No error, no warning, just wrong data, and you can lose an afternoon to it before you even suspect the module graph.
Hooks are a module-level trick
To see why a second copy breaks things, you have to look at what useState actually is, because it can't be what it pretends to be. When your component calls useState, the function has no argument telling it which component instance is asking, no fiber, no id, nothing. It works anyway because react-dom sets a shared mutable slot right before calling your component, a "current dispatcher" that lives at module scope inside the react package, and useState just reads that slot and delegates. The entire hooks API is two packages coordinating through a module-level singleton. React even tells you how load-bearing and private this is by the export name it travels under, which in React 18 was literally __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.
Now duplicate the package. The renderer is react-dom copy A, and during render it sets the dispatcher on react copy A's shared slot. Your component, bundled elsewhere, imported useState from react copy B. Copy B's dispatcher slot was never set, it's null, and a null dispatcher is precisely the condition that throws "invalid hook call." The error text blames your component, but nothing about the component is wrong. Two module instances each hold half of a conversation that was designed to happen inside one.
Context fails the same way, just silently. createContext returns an object, and that object's identity is the whole mechanism: the Provider writes the current value into the context object it was created from, and useContext reads from the context object you pass it. If the Provider came from copy A and your useContext received copy B's context object, the write and the read touch two different objects, the value never lands, and the consumer falls back to the default value like no Provider exists. Nothing throws, because nothing is illegal. You just have two parallel context systems that never talk.
How you end up with two
The honest answer is "more ways than you'd think," and the fix differs depending on which one you're in.
The classic is a failed dedup. Two things in your tree depend on React with version ranges that no single version satisfies, so the package manager does the correct thing and nests a second copy inside the stricter dependent. Nobody chose this, it fell out of range arithmetic in somebody's package.json, usually a UI library that pinned React instead of declaring it as a peer.
The one that bites library authors is npm link. You're developing a component library against a real app, so you link it. The library has React in its own node_modules, a devDependency it needs for tests and Storybook, and when the app's bundler follows the symlink it resolves the library's import React by walking up from the file's real location on disk, straight into the library's own copy. The app renders with its React, the library's components call hooks on theirs. Everything type-checks, everything builds, the first render throws. Symlinked development is basically a machine for manufacturing this bug, which is why it's the first thing to suspect the moment "invalid hook call" appears in a linked setup.
Monorepos have their own variants. Workspace hoisting can leave two apps on two React versions with shared packages resolving to one or the other depending on install order. pnpm, doing the strictly correct thing, instantiates a package once per peer-dependency combination, so a shared component library resolved against two React versions in the same workspace genuinely exists twice, on purpose. And module federation setups double React by default, since every remote bundles its own unless you explicitly declare it shared and singleton.
There's also a version of this where the version number is identical and you still get two copies. A package that ships both CJS and ESM builds can be loaded through both in one process, some of your graph requiring it while other parts import it, and since module caches key on the resolved file rather than the package name, the two builds are two module instances with two sets of module-level state. The dual-package hazard, in Node's terminology. It doesn't tend to hit React itself, but it hits the same class of library, anything stateful at module scope.
Confirming it
Before touching config, get proof. The package manager will tell you the truth about the tree:
npm ls react # every copy, and who pulled it in
pnpm why react
yarn why react
Enter fullscreen mode Exit fullscreen mode
One line mentioning deduped is health. Two distinct entries with different paths is your bug.
For symlink setups where the tree looks clean but you're still suspicious, check identity at runtime, which is the test that can't lie:
// in the app
window.React1 = require("react");
// in the library entry
window.React2 = require("react");
// in the console
window.React1 === window.React2; // false = two copies
Enter fullscreen mode Exit fullscreen mode
If you have a bundle analyzer wired up, it gives the same answer visually, react appearing twice at two paths.
The fixes
On the app side, the durable workaround is forcing resolution to a single path. Vite has it as a first-class option:
// vite.config.js
export default {
resolve: {
dedupe: ["react", "react-dom"],
},
};
Enter fullscreen mode Exit fullscreen mode
and in webpack the same move is an alias, resolve.alias: { react: path.resolve("./node_modules/react") }. Every import of react anywhere in the graph, symlinked or nested, now lands on one file, so there's one module instance and one dispatcher. For linked-library development this is less a workaround than standard equipment, and tools like yalc, which copy instead of symlinking, dodge the problem from the other side.
On the library side there's a real fix, and it's the one that protects your users instead of asking them to defend themselves: React belongs in peerDependencies, never in dependencies, with a copy in devDependencies for your own tests.
{
"peerDependencies": { "react": ">=18" },
"devDependencies": { "react": "^19.0.0" }
}
Enter fullscreen mode Exit fullscreen mode
A peer dependency is exactly this contract made explicit, "I use React but I must share your instance." And the same rule applies at bundle time, react marked external in your rollup or tsup config, because a copy of React compiled into your dist files is a second copy nothing can dedupe away. Module federation has its own spelling of the contract, shared: { react: { singleton: true } }.
Packages aren't singletons, resolutions are
The React story is just the loudest instance of something more general, and the general version explains a family of bugs that look unrelated.
At runtime there's no such thing as a package. Node's CJS cache keys on resolved filename, the ESM cache keys on URL, and bundlers key on resolved module path, so "one package" is really "however many distinct resolutions your graph produces." Module-level state is a singleton only while that count is one. Which means any library holding state at module scope, a dispatcher, a context registry, a class definition used in instanceof checks, has silently signed the same contract React signed, and breaks in its own way when the count hits two.
You've probably met the other family members. graphql-js checks for this explicitly and throws "Cannot use GraphQLSchema from another module or realm," one of the most helpful errors in the ecosystem, because they decided a loud failure beats a confusing one. styled-components warns about several instances being initialized and themes that vanish. An err instanceof MyCustomError returning false for an error that is, by any human reading, exactly that class, because the class was defined twice and the handler compared against the other one. Zod schemas failing instanceof ZodType inside a linked package. None of these are separate bugs to memorize, they're one bug wearing different libraries.
The mental model that survives all of it: npm installs packages, but the runtime only ever sees modules, one instance per resolved path, and identity is the only thing module-level state can coordinate on. If you consume libraries, npm ls is how you count instances when identity-shaped weirdness appears. If you write one and you hold anything at module scope, you're a singleton by contract, so declare it with a peer dependency, and consider checking identity at runtime and failing loudly, the graphql-js way, because your users will hit this, probably via npm link, probably on a Friday.


