What peerDependencies Actually Protect You From

10 min read

It’s Monday, and you’re adding dark mode.

Your app gets its buttons from a component library, @acme/ui. The library takes its theme from another package, @acme/theme, and to set the theme you need that package’s ThemeProvider. Your code will import it directly, so you install it:

my-app $ npm install @acme/theme

Then you wrap the app:

my-app/src/App.jsx
import { ThemeProvider } from "@acme/theme";
import { Button } from "@acme/ui";

export function App() {
  return (
    <ThemeProvider theme="dark">
      <Button>Save</Button>
    </ThemeProvider>
  );
}

@acme/theme is small. Its first version, 1.0.0, is a few lines around a React context with "light" as the default. ThemeProvider goes near the top of the app and sets the theme, and useTheme() reads it from any component inside:

@acme/theme/index.jsx
import { createContext, useContext } from "react";

const ThemeContext = createContext("light");

export function ThemeProvider({ theme, children }) {
  return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  return useContext(ThemeContext);
}

The app uses the first half. @acme/ui uses the second, in its button:

@acme/ui/Button.jsx
import { useTheme } from "@acme/theme";

export function Button({ children }) {
  const theme = useTheme();
  return <button className={theme}>{children}</button>;
}

You reload the page:

<button class="light">Save</button>

The button got light, the default, as if the provider weren’t there. Nothing threw, and nothing printed a warning. You check the spelling of "dark". You check that the button is inside the provider. It is.

You may have met this bug in another library. React Router is built the same way: your app renders a <Router>, and components inside it call useNavigate(). If you’ve ever seen useNavigate() may be used only in the context of a <Router> component with a <Router> clearly around it, it may have been the same bug.

The cause isn’t in your code. It’s in node_modules.

Two Copies, Two Contexts#

You ask npm what it installed:

my-app $ npm ls @acme/theme
my-app@1.0.0 /path/to/my-app
├── @acme/theme@2.0.0
└─┬ @acme/ui@1.0.0
  └── @acme/theme@1.0.0

There are two copies of @acme/theme: v2 at the top of node_modules, and v1 nested inside @acme/ui’s folder. You can run the same check on your own project. The end of this post shows what to look for.

Two copies matter because of how an import finds a package. For import "@acme/theme", Node starts at the importing file’s folder, looks in node_modules there, and if the package isn’t there, moves to the parent folder and tries again. Bundlers do the same by default: webpack’s docs describe it as looking through ./node_modules, ../node_modules, and on. So the same import line finds a different copy depending on which file it’s in:

  1. 1The same import finds two different copies

    • my-app/src/App.jsx

      import { ThemeProvider } from "@acme/theme";

      my-app/node_modules/@acme/theme 2.0.0

    • my-app/node_modules/@acme/ui/Button.jsx

      import { useTheme } from "@acme/theme";

      my-app/node_modules/@acme/ui/node_modules/@acme/theme 1.0.0

    Node starts in the importing file's folder and takes the first node_modules/@acme/theme it finds on the way up.

  2. 2Each copy creates its own context

    Your copy, 2.0.0, runs createContext("light")

    ThemeContext

    Your ThemeProvider provides "dark" for this one.

    is a different object from

    @acme/ui's copy, 1.0.0, runs createContext("light")

    ThemeContext

    The button's useTheme() reads this one. No provider uses it, so reading it returns its default, "light".

    Same name, same code, two separate objects.

  3. 3React matches a provider by object, not by name

    Read it from the bottom up: useTheme() starts at the button and climbs the tree.

    1. <App>

      Reaches the top with no match, so it returns the default, "light"

    2. <ThemeProvider theme="dark">

      Passes your provider. It's for ThemeContext from 2.0.0, not the object it's looking for.

    3. <Button>
    4. useTheme()

      Starts here, looking for a provider of ThemeContext

    Renders<button class="light">Save</button>

Node picks a copy by where the import is. React matches a provider by its context object. Two copies make two objects, so the button's lookup walks right past your provider.

Each copy ran its own createContext("light"), so there are two ThemeContext objects with the same name. React matches a hook to a provider by the object, not the name. So the library’s useTheme() looks up the tree, passes your provider because it’s for v2’s context, finds none for v1’s, and returns the default, "light". React’s useContext docs describe the same failure under “My component doesn’t see the value from my provider”: the provider and the hook end up with “two different objects”.

Context is just one example. Anything a library keeps at module level splits in two: a store, a cache, a registry, a class you check with instanceof. It’s also behind one of React’s best-known errors. Among the causes of “Invalid hook call”, React’s docs list a duplicate React: “the react import from your application code needs to resolve to the same module as the react import from inside the react-dom package.”

How the Second Copy Got There#

You never asked for v2. npm install @acme/theme installs the latest version and writes "@acme/theme": "^2.0.0" into your package.json. Nothing tells you @acme/ui was already using v1. pnpm add does the same.

And @acme/ui lists the theme package as a regular dependency:

@acme/ui/package.json
{
  "name": "@acme/ui",
  "version": "1.0.0",
  "dependencies": { "@acme/theme": "^1.0.0" }
}

No version satisfies both ^1.0.0 and ^2.0.0, so npm gives each one what it asked for. Your copy goes at the top of the tree, and @acme/ui gets its own nested copy.

You change your range to ^1.0.0 and install again. npm puts one copy at the top, and @acme/ui uses it:

my-app $ npm ls @acme/theme
my-app@1.0.0 /path/to/my-app
├── @acme/theme@1.0.0
└─┬ @acme/ui@1.0.0
  └── @acme/theme@1.0.0 deduped
<button class="dark">Save</button>

Fixed. But a regular dependency gives you one copy only while the ranges happen to agree, and nobody checks that they do. You open an issue on @acme/ui saying so.

What peerDependencies Change#

A week later, the @acme/ui team ships 2.0.0. It moves the theme package to peerDependencies:

@acme/ui/package.json
{
  "name": "@acme/ui",
  "version": "2.0.0",
  "peerDependencies": { "@acme/theme": "^1.0.0" },
  "devDependencies": { "@acme/theme": "^1.0.0" }
}

A dependency says “install this for me.” A peer dependency says “the project that installs me provides this, and it has to be in this range.” @acme/ui never gets a copy of its own. Its import walks up to your copy, the same one your code uses. (The devDependencies entry is only for the library’s own tests and builds. It isn’t installed for anyone who uses the library.)

A peer dependency is a socket, not a battery. A regular dependency can give the library its own copy, like a lamp with its own battery. That’s why the button never saw your provider. With a peer dependency, the library uses your copy, like a lamp plugged into a socket. The plug has to fit, and the range says what fits.

You’re on ^1.0.0, so it fits. One copy, dark buttons.

When the Ranges Disagree#

A month later it’s upgrade week. v1 of @acme/theme isn’t getting fixes anymore, so this time you want v2 on purpose. You bump your range to ^2.0.0. @acme/ui still asks for ^1.0.0.

When you added dark mode, this mismatch got you a quiet second copy. With a peer dependency, npm doesn’t install a second copy. It refuses to install at all:

my-app $ npm install
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: my-app@1.0.0
npm error Found: @acme/theme@2.0.0
npm error node_modules/@acme/theme
npm error   @acme/theme@"^2.0.0" from the root project
npm error
npm error Could not resolve dependency:
npm error peer @acme/theme@"^1.0.0" from @acme/ui@2.0.0
npm error node_modules/@acme/ui
npm error   @acme/ui@"^2.0.0" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry this command with --force or --legacy-peer-deps to accept an incorrect (and potentially broken) dependency resolution.

With a regular dependency, you found out at runtime, because you happened to look at the button. With a peer dependency, you find out at install, and the error names both packages and both ranges.

That’s what peerDependencies actually protect you from. They can’t make two ranges agree, but they stop a disagreement from turning into a silent second copy. Most of the rest of this story is about ways to switch that check off.

npm hasn’t always done this. In npm 3 through 6, peer dependencies weren’t installed, and a wrong version only raised a warning. Since npm 7, they’re installed by default, and a conflict like this one is an error. If you’ve upgraded an old project and suddenly got ERESOLVE errors, this is why.

What --legacy-peer-deps Gives Up#

You’ve probably been here, even if the packages had other names.

The last line of the error has a suggestion. Fixing the upstream conflict means waiting for the @acme/ui team to release a version that accepts v2. That could take weeks. The flag takes one retry:

my-app $ npm install --legacy-peer-deps

added 5 packages, and audited 6 packages in 826ms

found 0 vulnerabilities

No warning. CI fails with the same ERESOLVE, so you add one line to .npmrc, and CI passes too:

my-app/.npmrc
legacy-peer-deps=true

You ship it.

The next morning there’s a bug report: every button lost its styling. You open DevTools:

<button class="[object Object]">Save</button>

That’s what changed in @acme/theme v2. useTheme() now returns an object, { theme }, instead of a string. The button still passes it straight to className. React writes class as a string, so it converts the object, and every plain object converts to [object Object].

npm saw this coming. That was the ERESOLVE. It still says so if you ask:

my-app $ npm ls @acme/theme
my-app@1.0.0 /path/to/my-app
├── @acme/theme@2.0.0
└─┬ @acme/ui@2.0.0
  └── @acme/theme@2.0.0 deduped invalid: "^1.0.0" from node_modules/@acme/ui

--legacy-peer-deps ignores peer dependencies entirely, the way npm 3 to 6 did, but without even their warning. There’s one copy, so the provider and the hook share a context. But @acme/ui now runs against a major version it never said it supports.

The error’s other suggestion, --force, ends the same way: one copy of v2, invalid in npm ls, and [object Object]. Here, the only visible difference is the warning it prints first. It also turns off npm’s other safety checks, not just this one.

Neither flag fixes the conflict. Each one just stops npm install from failing on it, and you’re back to finding out at runtime.

pnpm Warns Instead#

With pnpm, you wouldn’t have needed the flag. With the same mismatch, pnpm install succeeds and prints one line:

my-app $ pnpm install
[WARN] Issues with peer dependencies found. Run "pnpm peers check" to list them.

It installs v2 out of range, like npm with --legacy-peer-deps, and the bug report arrives all the same. pnpm peers check shows the details:

my-app $ pnpm peers check
Issues with peer dependencies found

✕ unmet peer @acme/theme
  Installed: 2.0.0
  Wanted:
    ^1.0.0:
      @acme/ui@2.0.0

To make it an error, turn on strictPeerDependencies (off by default). The install then fails with ERR_PNPM_PEER_DEP_ISSUES.

The rest matches npm. A regular dependency with a different range still gets its own copy (Found 2 versions of @acme/theme), and with a peer there’s never a second copy.

When You Can’t Change the Library#

You take the flag out of .npmrc and go back to v1. The buttons are dark again. But @acme/ui still hasn’t widened its range, and v1 still isn’t getting fixes.

In code review, a teammate suggests a cleaner way to get onto v2. You can’t edit the library’s package.json, but you can overrule it from yours with overrides:

my-app/package.json
{
  "name": "my-app",
  "version": "1.0.0",
  "dependencies": {
    "@acme/theme": "^2.0.0",
    "@acme/ui": "^2.0.0"
  },
  "overrides": {
    "@acme/theme": "^2.0.0"
  }
}

Because you also depend on @acme/theme directly, the override has to match your own range exactly ("2.0.0" here fails with EOVERRIDE). Every package in the tree now gets your copy, and the ERESOLVE is gone:

my-app $ npm ls @acme/theme
my-app@1.0.0 /path/to/my-app
├── @acme/theme@2.0.0
└─┬ @acme/ui@2.0.0
  └── @acme/theme@2.0.0 deduped

No invalid this time either. But nothing changed in the button, so it still gets an object from useTheme():

<button class="[object Object]">Save</button>

An override decides which version a library gets. It can’t make the library work with that version. It’s the same trade as --legacy-peer-deps, with one difference that’s better and one that’s worse. The better one: the decision lives in your package.json, where the next person can see it. The worse one: the tools stop complaining. npm ls passes, so nothing points at it later.

Overrides work on regular dependencies too. On @acme/ui v1, the same override removes the nested copy, and the button renders [object Object] there as well.

So reach for an override when you’ve checked that the library works with the version you’re forcing, for example when its range is just older than it needs to be. Write down why (JSON has no comments, so use the commit message or a link to the library’s issue), and remove the override once the library updates its range.

In pnpm, overrides go in pnpm-workspace.yaml:

my-app/pnpm-workspace.yaml
overrides:
  "@acme/theme": "^2.0.0"

Older answers put them under a "pnpm" field in package.json. pnpm 12 ignores that field, and the only sign is one line in the install output:

my-app $ pnpm install
[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.

What About "*"?#

You remove the override and open an issue on @acme/ui asking for v2 support. You’re not the only one: the tracker is full of ERESOLVE reports. The @acme/ui team ships 3.0.0 to stop them:

@acme/ui/package.json
{
  "name": "@acme/ui",
  "version": "3.0.0",
  "peerDependencies": { "@acme/theme": "*" },
  "devDependencies": { "@acme/theme": "^1.0.0" }
}

Only the range changed. The button still expects useTheme() to return a string. (Widening a range doesn’t break anyone, so a real library would ship this as a minor, 2.1.0. This story gives each change its own major so the versions are easy to tell apart.)

You install it with your range on ^2.0.0:

my-app $ npm ls @acme/theme
my-app@1.0.0 /path/to/my-app
├── @acme/theme@2.0.0
└─┬ @acme/ui@3.0.0
  └── @acme/theme@2.0.0 deduped

One copy, no ERESOLVE, and no invalid. pnpm agrees, even with strictPeerDependencies on:

my-app $ pnpm peers check
No peer dependency issues found

And the button:

<button class="[object Object]">Save</button>

"*" keeps the half of a peer dependency that gives you one copy, and drops the half that checks the version. It’s a plug that fits every socket, whatever the voltage. You get what --legacy-peer-deps gives you, except the library chose it for everyone who installs it.

That can be a reasonable trade when something else does the checking. Some teams use "*" for internal packages that are released together: one package pins the shared version for every app, and CI runs each library’s tests against that version. Then "*" saves updating ranges on every major. Without a check like that, "*" means nobody checks.

A Range the Library Tested#

You post the broken button on the thread. The real fix had to happen in the library. The @acme/ui team made the button handle both shapes of useTheme() and listed both majors in the range. devDependencies holds the one they develop against, and CI runs the tests against each:

@acme/ui/package.json
{
  "name": "@acme/ui",
  "version": "4.0.0",
  "peerDependencies": { "@acme/theme": "^1.0.0 || ^2.0.0" },
  "devDependencies": { "@acme/theme": "^1.0.0" }
}
@acme/ui/Button.jsx
import { useTheme } from "@acme/theme";

export function Button({ children }) {
  const value = useTheme();
  const theme = typeof value === "string" ? value : value.theme;
  return <button className={theme}>{children}</button>;
}

You install it with your range on ^2.0.0:

my-app $ npm ls @acme/theme
my-app@1.0.0 /path/to/my-app
├── @acme/theme@2.0.0
└─┬ @acme/ui@4.0.0
  └── @acme/theme@2.0.0 deduped
<button class="dark">Save</button>

One copy, a check that passes because it’s true, and a dark button. An app still on v1 gets the same. When v3 of the theme comes out, the range stays shut until the tests say it works.

All Eight Cases#

Here’s the whole story in one figure: the four ways @acme/ui listed the theme package, against both versions you can ask for. Each one is a real run of the demo repo, which you can run yourself. Try them. Only the tested range gives you one copy and a check that means something, whichever version you ask for:

@acme/ui lists theme in
my-app asks for theme
my-app $ npm ls @acme/theme
my-app@1.0.0 /path/to/my-app
├── @acme/theme@2.0.0
└─┬ @acme/ui@1.0.0
  └── @acme/theme@1.0.0

Renders

<button class="light">Save</button>

Two copies. The provider and the hook use different contexts, and nothing warns you.

Each tree and button comes from a real run of the demo with npm.

Who Should List What#

So the field you choose is about who provides the package:

  • A library that needs to share a package with the app (React itself, a router, a theme or state package) lists it in peerDependencies, with a range as wide as it has tested (not "*", unless something else checks versions), and in devDependencies for its own development.
  • The app provides the one copy. It lists the package in dependencies, with a version that satisfies every peer range in the tree.

Not every library does this. If one you use lists the shared package as a regular dependency, like @acme/ui v1, that library decides the version. When your app imports the shared package too, use a range that agrees with the library’s. Don’t just run npm install and take the latest.

Whether the app puts it under dependencies or peerDependencies matters less than you’d think. npm 7+ and pnpm both install a root project’s peer dependencies, so either way the package gets installed. What decides how many copies you get is whether the ranges agree.

Checking Your Own Project#

You don’t have to guess. Ask the package manager how many copies there are:

npm ls @acme/theme      # npm
pnpm why @acme/theme    # pnpm
  • One version, and every other line says deduped: one copy.
  • Two versions (pnpm says Found 2 versions): find who pulled in each one, and make their ranges agree. npm dedupe and pnpm dedupe won’t do it for you. They can only merge copies when one version satisfies every range, so here both report “up to date” and leave both copies. npm find-dupes doesn’t list this kind of duplicate either.
  • invalid in npm ls, or a peer warning from pnpm: something runs outside the range it asked for.

None of these catch a "*" range or an override: both pass every check. To see what a library really accepts, open its package.json in node_modules. To see what you’ve forced, look for overrides in your own package.json (or pnpm-workspace.yaml), and for legacy-peer-deps in .npmrc.

The Short Version#

A peer dependency is a socket, not a battery.

  • An import finds a package by walking up node_modules from the importing file. Two copies of a library means two separate modules, each with its own contexts, state, and classes.
  • A regular dependency gives you a second copy as soon as two ranges disagree, and the bug only shows at runtime.
  • A peer dependency never makes a second copy. A mismatch is reported at install: an error in npm 7+, a warning in pnpm unless strictPeerDependencies is on.
  • --legacy-peer-deps, overrides, and a peer range of "*" all keep one copy by dropping the version check ("*" drops it for everyone who installs the library). Use an override when you can’t change the library, and only once you’ve checked that the library works with the version you force.
  • A peer range the library has tested, like "^1.0.0 || ^2.0.0", gives you one copy and a check you can trust.
  • npm ls and pnpm why tell you how many copies you actually have.

← All posts