You run npm install and npm refuses to build the tree. This is the exact output from npm 10.9.8 (bundled with Node v22.23.2):
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: demo@1.0.0
npm error Found: react@18.3.1
npm error node_modules/react
npm error react@"^18.3.1" from the root project
npm error
npm error Could not resolve dependency:
npm error peer react@"^16.9.0 || ^17.0.0" from @testing-library/react-hooks@8.0.1
npm error node_modules/@testing-library/react-hooks
npm error @testing-library/react-hooks@"^8.0.1" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.Older npm releases (npm 7 to 9, and early npm 10 releases) print the same report with an npm ERR! prefix instead of npm error. Either way it means one package declared a peer dependency range that the version already in your project does not satisfy, and npm will not guess which side should win.
Quick fix checklist
- Read the two key lines:
Found: react@18.3.1(what you have) andpeer react@"^16.9.0 || ^17.0.0" from @testing-library/react-hooks@8.0.1(who objects). - Check whether a newer version of the objecting package accepts your version:
npm view @testing-library/react-hooks peerDependencies. - Upgrade the objecting package, or replace it if it is abandoned.
- If you are mid-migration and have tested the combination, add a scoped
overridesentry inpackage.jsonand a comment in your PR explaining why. - Treat
--legacy-peer-depsand--forceas temporary diagnostics, not the fix. - Commit
package-lock.jsononce the install succeeds so CI resolves the same tree.
Before you start
You need npm 7 or newer (npm 10 or 11 if you are on Node 22 or 24 LTS); check with npm -v. npm 3 to 6 did not install peer dependencies and only printed a warning, which is why projects that installed fine years ago suddenly fail after a Node upgrade. Know the difference between dependencies (installed for the package), devDependencies (installed for development) and peerDependencies (the package expects the host project to provide one shared copy).
Why it happens
Some packages must share a single instance with your application: a React component library must use your React, an ESLint plugin must plug into your ESLint, a Vite plugin must run inside your Vite. Those packages declare the host as a peer dependency with a version range, meaning “I was built and tested against these versions of the thing you provide.”
Since npm 7, npm installs peer dependencies automatically and requires the whole tree to satisfy every declared peer range. If your root project pins react@^18.3.1 and a package says it supports only ^16.9.0 || ^17.0.0, there is no single React version that satisfies both, and there can only be one React next to that package. npm stops with ERESOLVE rather than silently installing something a package author said would not work.
The report has a fixed structure:
- While resolving: the package whose dependencies npm was placing when it hit the conflict (often your root project).
- Found: the version already chosen, with the dependency path that put it there (
react@"^18.3.1" from the root project). - Could not resolve dependency: the requirement that conflicts, and the chain that brought the requiring package in.
- Conflicting peer dependency (only in some reports): the version npm would have needed.
The real causes are almost always one of these: a package that has not published support for your framework’s new major version, a package that did publish support but your lockfile or package.json pins an old major of it, or two packages that genuinely require different majors of the same peer.
Step-by-step walkthrough
Step 1: Reproduce it without touching node_modules
--dry-run builds the ideal tree and prints what it would change without writing anything, so you can experiment safely:
npm install --dry-runnpm also writes a copy of the report to a file named in the output (For a full report see: ~/.npm/_logs/<timestamp>-eresolve-report.txt), which is handy to paste into an issue or PR.
Step 2: Identify the objecting package and its peer range
From the report above, the objection comes from @testing-library/react-hooks@8.0.1. Ask the registry what its versions support:
npm view @testing-library/react-hooks peerDependencies
npm view @testing-library/react-hooks versions --json{
react: '^16.9.0 || ^17.0.0',
'react-dom': '^16.9.0 || ^17.0.0',
'@types/react': '^16.9.0 || ^17.0.0',
'react-test-renderer': '^16.9.0 || ^17.0.0'
}If the latest version still does not include your major, the package has not caught up, and you need a replacement or an override. Read its changelog or README before assuming either; sometimes the project tells you where the functionality moved.
Step 3: See why each side is in your tree
When the conflicting package is not something you installed directly, find who pulled it in:
npm explain react
npm ls react --allnpm explain walks the dependency chain backwards from each installed copy to your package.json. That tells you which direct dependency to upgrade. Bumping a transitive package by hand does not stick, because the parent’s range decides what npm installs next time.
Step 4: Fix the conflict at its source
In order of preference:
- Upgrade the objecting package to a version whose peer range includes yours:
npm install some-plugin@latest, then re-run the tests that exercise it. - Replace it if it is unmaintained or its functionality moved elsewhere.
- Align your own version if you upgraded the host too early, for example staying on the previous major until your plugins catch up.
- Override, scoped and documented, when you have verified the combination works and are waiting on an upstream release.
An override replaces a version requirement for a specific part of the tree. $react means “whatever version the root package.json declares for react”, so the override follows your upgrades:
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/react-hooks": "^8.0.1"
},
"overrides": {
"@testing-library/react-hooks": {
"react": "$react",
"react-dom": "$react-dom"
}
}
}With this file, npm install --dry-run succeeds. Keep overrides nested under the package that needs them; a top-level "react": "..." override changes React for every package in the tree, which is a much bigger claim.
Step 5: Prevent the next one
Upgrade framework majors deliberately: list the plugins that peer on the framework, check each one’s peer range before you bump, and upgrade them in the same PR. npm outdated shows which direct dependencies are behind. Keep the lockfile committed and use npm ci in CI, which fails if package.json and the lockfile disagree instead of quietly resolving a different tree.
Worked scenario
A frontend team upgrades from React 17 to React 18. Their package.json keeps @testing-library/react-hooks@^8.0.1 for hook tests. The next fresh npm install fails with the report at the top of this page.
Diagnosis: npm view @testing-library/react-hooks peerDependencies shows no version of the package accepts React 18, and its README has a section on React 18 support explaining that renderHook was added to @testing-library/react itself. The conflict is not a bug to suppress; it is the package telling them it has been superseded.
Fix: remove the old package and use the official API.
npm uninstall @testing-library/react-hooks
npm install --save-dev @testing-library/react @testing-library/dom// before
import { renderHook, act } from '@testing-library/react-hooks';
// after
import { renderHook, act } from '@testing-library/react';@testing-library/react 16 declares react: '^18.0.0 || ^19.0.0' and lists @testing-library/dom as a peer, which is why it is installed explicitly. The install succeeds with no flags and the next React upgrade is already covered.
Common mistake
The error message itself suggests --force or --legacy-peer-deps, so that is what most people try. They are different, and both leave the conflict in place:
--legacy-peer-depstells npm to ignorepeerDependenciescompletely, as npm 6 did. It does not install peers and does not check them. The install succeeds, but nothing tells you about this conflict or any future one. Teams then commitlegacy-peer-deps=trueto.npmrcso CI passes, which silences every later warning as well.--forcekeeps peer handling on but lets npm accept a tree it knows is invalid. It printsnpm warn using --force Recommended protections disabled.followed bynpm warn ERESOLVE overriding peer dependency. It also disables other safety checks unrelated to peers. And the escape is not durable: in a test, a lockfile written bynpm install --forcemade a later plainnpm cifail with ERESOLVE again, so every teammate and CI job needs the flag too.
In both cases you get the versions the package author said do not work together. Sometimes they work anyway; sometimes you get two copies of React and the “Invalid hook call” error at runtime, or a plugin that calls an API the host removed. Use the flags to answer “would the install otherwise succeed?” and then fix the conflict properly. If you have tested the combination, an overrides entry records that decision in package.json, scoped to one package, where reviewers can see it.
Deleting package-lock.json is another common reflex. It does not fix a peer conflict; it only upgrades unrelated packages at the same time.
Verify the behavior
npm install --dry-runcompletes withoutERESOLVEand withoutnpm warn ERESOLVE overriding peer dependency.npm ls reactshows a single copy with noinvalidmarker. For comparison, a tree installed with--forcelooks like this, andnpm lsexits with status 1 andnpm error code ELSPROBLEMS, so it works as a CI check:
demo@1.0.0
+-- react-dom@18.3.1
| `-- react@17.0.2 deduped invalid: "^18.3.1" from node_modules/react-dom
`-- react@17.0.2 invalid: "^18.3.1" from node_modules/react-dom- A clean install from the lockfile succeeds:
rm -rf node_modules && npm ci. - The tests that cover the upgraded package pass, because a resolved tree only proves npm is satisfied, not that the code works.
Interview exercise
“After upgrading Node, npm install started failing with ERESOLVE on a project nobody changed. A colleague proposes adding legacy-peer-deps=true to .npmrc. What is happening and what would you recommend?”
Answer and reasoning
The Node upgrade brought a newer npm. npm 6 printed peer dependency problems as warnings and did not install peers, so the conflict existed all along and was ignored. npm 7 and later enforce peer ranges, so the same package.json now fails. The .npmrc change restores npm 6 behaviour project-wide, which hides this conflict and every future one, including ones that cause runtime bugs such as duplicate framework copies.
I would read the report to find the package whose peer range excludes the installed version, check whether a newer release or a replacement supports it, and upgrade in the same change. If upstream has not shipped support but tests pass with the newer host version, I would add a scoped overrides entry with a link to the upstream issue, so the exception is explicit, reviewable and easy to remove. The general principle is that the resolver is reporting a real compatibility claim, and the fix should change either the claim (upgrade the package) or record an informed exception, not switch off the check.