If you have seen Invalid hook call and then run npm ls react and found a single version, this post is for you.
The problem
The React docs list more than one copy of React in the same app as one of three causes of that error. The tricky part is that two copies can have the same version. Say your app depends on a component library through npm install ../ui-kit or npm link. The library has React as a dev dependency for its own tests, so it carries its own node_modules/react. Node resolves imports from the real location of a file, so the library loads its own copy while the app and react-dom load another. Both say 19.1.0. React sees two modules, and the hooks state of one is invisible to the other.
npm ls react and pnpm why react describe what is installed. They do not tell you which directory each importer actually resolves.
What onecopy does
npx github:Arthur031221/onecopy react
It starts from each importer: your project, every workspace member, and every package in its dependency tree that declares React as a dependency or peer. For each one it repeats the node_modules lookup Node performs from the importer's real path, then groups the answers by real directory. The output names the copy your project uses, the extra copies, who resolves each, and the likely cause: a linked library with its own install, a copy nested under an installed package, a pnpm peer variant, or plain version drift.
Independent apps in a workspace are never compared with each other. Each project is checked against the libraries connected to it.
What I checked
I ran it on a real npm install of a file: dependency and on a real pnpm workspace with mismatched React versions, and it reported both. On a clean install of Next.js, MUI, React Query and React Router it reported one copy for React and for react-dom. The test suite has 25 tests, including a run against real npm.
What it does not do
It reads Node resolution only. Vite resolve.dedupe or webpack resolve.alias can merge copies when you bundle, so when a bundler config in the project mentions either word, onecopy says the bundled result is unverified. Yarn Plug'n'Play has no node_modules to inspect and is not supported.
The exit status is 1 when a duplicate exists, which makes it usable as a CI check. --json gives the full data for bug reports.
Source and issues: https://github.com/Arthur031221/onecopy

Top comments (4)
This is a really good example of why dependency debugging can’t always be reduced to “what version do I have?”
The interesting part is that both React copies can report 19.1.0 and everything can look perfectly clean in
npm ls, while Node is still loading two different module instances because they come from different real paths.That distinction between version identity and module identity is easy to miss.
I’ve seen the same kind of problem in other systems too: two components can have the same version, same API, and even the same package name, but if the runtime reaches them through different resolution paths, they can still behave like completely different instances.
That’s why I like the approach here. Instead of asking “what is installed?”, you’re asking the more useful question: “what does this importer actually resolve at runtime?”
And turning that into a CI check is a nice touch. A weird Invalid Hook Call discovered during production debugging is expensive. A failed build saying “you have two React instances and here is who resolves each one” is a much smaller problem.
The
--preserve-symlinksedge case in the comments also shows why modeling runtime resolution matters. Package metadata tells part of the story. The runtime path tells the rest.Thanks, that is the distinction I wanted to make: the version can match while the resolved module path does not. The CI check is only useful if it reflects the runtime's resolution policy. onecopy currently models Node's default lookup from real paths, so
--preserve-symlinksremains a known gap. The app peer versus outer peer fixture is a good way to make that difference visible, and I'll use it when I add reporting for the selected symlink policy."Does onecopy account for
--preserve-symlinkswhen modeling the application's runtime, or explicitly assume default Node resolution?A useful extra fixture is a linked library with no local peer, an app-level peer and a different peer above the library's real directory. An AI-run synthetic CommonJS check on Node 24.18.0 loaded the outer peer by default and the app peer with the flag. Node documents this change in the lookup root.
Recording the application's symlink policy alongside importer paths could help distinguish a reported duplicate from the copies it actually loads. This check used tiny stand-in packages, not React or onecopy itself.
You're right: onecopy currently models Node's default behavior by resolving from each importer's real path. It does not account for
--preserve-symlinks, so its result can differ from a runtime started with that flag.The fixture you described is a good regression case because it makes the changed lookup root observable even when versions alone are not useful. I should add it for both default and preserved symlink resolution, then expose the selected policy in text and JSON output. Until that is implemented, I will document the default-resolution assumption and mark results as unverified when the flag is detected or explicitly supplied. Thanks for the precise example.