---
title: "What peerDependencies Actually Protect You From · Ihab Khattab"
description: "Why two copies of one package break an app without an error, how to find them, and what peer ranges, *, overrides, and --legacy-peer-deps really do."
url: "https://ihabkhattab.com/blog/what-peerdependencies-actually-protect-you-from/"
---

# What peerDependencies Actually Protect You From

Nov 6, 2026 · 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:

```sh
my-app $ npm install @acme/theme
```

Then you wrap the app:

_my-app/src/ App.jsx_

```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_

```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_

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

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

You reload the page:

```html
<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:

```txt
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](#checking-your-own-project) 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](https://nodejs.org/api/modules.html#loading-from-node_modules-folders), 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](https://webpack.js.org/configuration/resolve/#resolvemodules) 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

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

        my-app/node\_modules/@acme/theme 2.0.0

    - my-app/node\_modules/@acme/ui/Button.jsx

        ```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](https://react.dev/reference/react/useContext#my-component-doesnt-see-the-value-from-my-provider) 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](https://react.dev/warnings/invalid-hook-call-warning): “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_

```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:

```txt
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
```

```html
<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_

```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:

```txt
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](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#peerdependencies), 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:

```txt
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_

```ini
legacy-peer-deps=true
```

You ship it.

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

```html
<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:

```txt
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:

```txt
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:

```txt
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`](https://pnpm.io/settings/peer-dependencies#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`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#overrides):

_my-app/ package.json_

```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:

```txt
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()`:

```html
<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`](https://pnpm.io/settings/dependency-resolution#overrides):

_my-app/ pnpm-workspace.yaml_

```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:

```txt
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_

```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`:

```txt
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:

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

And the button:

```html
<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_

```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_

```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`:

```txt
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
```

```html
<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](https://github.com/ikhattab/peer-deps-demo), 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:

Figure: every combination of how @acme/ui lists @acme/theme and what my-app asks for, from real runs. As a dependency on ^1.0.0: one copy and a dark button when the app asks for ^1.0.0; two copies and a light button when it asks for ^2.0.0. As a peer on ^1.0.0: one copy and a dark button with ^1.0.0; npm fails with ERESOLVE with ^2.0.0 (pnpm warns, installs 2.0.0, and renders class="\[object Object\]"). As a peer on "\*": one copy and a dark button with ^1.0.0; one copy, no warning, and class="\[object Object\]" with ^2.0.0. As a peer on "^1.0.0 || ^2.0.0": one copy and a dark button with either.

_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:

```sh
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](https://ihabkhattab.com/blog/)
