Choosing Peer Dependencies vs Dependencies for Libraries
Decide whether a library dependency belongs in dependencies or peerDependencies, pick a range that will not break consumers, and fix duplicate-instance failures.
A component library declares React in dependencies, everything works in its own test suite, and the first consumer to install it gets:
Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of
a function component. This could happen for one of the following reasons:
3. You might have more than one copy of React in the same app
Reason three is nearly always the one. The dependency section a library chooses decides whether the consumer’s tree contains one copy of a package or two, and for anything holding module-level state the difference between one and two is the difference between working and not.
Root Cause
dependencies means “install this for me”. The package manager resolves it independently of what the consumer already has, and when the ranges do not overlap — or when the layout is not hoistable — the result is a second copy nested under your package. Two copies means two module registries, two sets of classes, and two lots of internal state.
peerDependencies means “the consumer must provide this, and there must be exactly one”. The package manager does not install a nested copy; it checks that a satisfying version exists at the top of the tree and warns or errors when it does not. For a framework, a plugin host, or any package whose identity matters, that guarantee is the entire point.
Minimal Reproduction
{
"name": "@scope/ui-kit",
"version": "1.0.0",
"dependencies": {
"react": "^18.2.0"
}
}
// src/Button.tsx
import { useState } from "react";
export function Button({ label }: { label: string }): JSX.Element {
const [pressed, setPressed] = useState(false);
return <button onClick={() => setPressed(!pressed)}>{label}</button>;
}
npm ls react
[email protected]
├── [email protected]
└─┬ @scope/[email protected]
└── [email protected] ← the second copy
Two entries in that tree, one broken application.
The Two Resolution Outcomes
Step-by-Step Fix
1. Move the shared package to peerDependencies
{
"name": "@scope/ui-kit",
- "dependencies": {
- "react": "^18.2.0"
- }
+ "peerDependencies": {
+ "react": ">=18"
+ },
+ "devDependencies": {
+ "react": "^18.3.1"
+ }
}
The devDependencies entry is not redundant: it gives your own tests and type-checking a version to resolve against, and consumers never install it.
2. Widen the range to what you actually support
A peer range is a compatibility claim, and a narrow one causes more support load than a wide one. ">=18" accepts every future major; if one eventually breaks the integration, a patch release narrowing the range fixes it for new installs without inconveniencing anyone in the meantime.
{
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}
3. Mark genuinely optional integrations optional
{
"peerDependencies": {
"react": ">=18",
"zod": ">=3.22"
},
"peerDependenciesMeta": {
"zod": { "optional": true }
}
}
An optional peer is a declaration of compatibility, not a requirement — consumers who never use the validation integration see no warning and install nothing. The code must then feature-detect rather than assume:
export async function validate(input: unknown): Promise<boolean> {
try {
const { z } = await import("zod");
return z.object({ id: z.string() }).safeParse(input).success;
} catch {
return typeof (input as { id?: unknown })?.id === "string"; // fallback
}
}
4. Keep the peer external in the build
Declaring a peer and then bundling it produces both problems at once: the consumer supplies a copy and your output contains one.
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
+ external: ["react", "react-dom", "zod"],
});
HAZARD PREVENTION
Symptom: Peers are declared correctly, but consumers still get two copies of React in their bundle analyser output.
Root cause: The build inlined React because the externals list was written by hand and drifted from the manifest — a common outcome when a peer is added months after the build config was written.
Fix: Derive the externals list from
package.jsonat build time (Object.keys(pkg.peerDependencies ?? {})) so the two can never disagree, and grep the built output for a framework-specific string as a check.
Verification
# exactly one copy in a real consumer install
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install react react-dom ./*.tgz --silent
npm ls react
# nothing framework-shaped was bundled
grep -c "react/jsx-runtime\|createElement" node_modules/@scope/ui-kit/dist/index.mjs || true
Expected: a single react@… line with no nested duplicate, and framework references appearing only as import statements.
Edge Cases / Gotchas
- pnpm’s strict layout surfaces missing peers as hard errors. A consumer on pnpm sees a failure where an npm user sees a warning, which makes the same misconfiguration look like a package-manager bug rather than a manifest one.
- Auto-installed peers can mask the problem in development. npm 7+ may install a satisfying peer automatically, so your own smoke test passes while a consumer with a conflicting version still gets two copies.
- Type-only peers still need declaring. If your public types reference a framework’s types, consumers need that package present at compile time even when no runtime import exists; an optional peer covers this without forcing an install.
- Monorepo hoisting hides duplication. Inside a workspace the framework is usually hoisted to the root, so a misclassified dependency behaves correctly in your own repository and only fails once published.
- A peer with a caret range is not a wide range.
^18.0.0excludes 19, so it produces exactly the unmet-peer wall that motivated moving to peers in the first place.
Frequently Asked Questions
What actually breaks when a framework is a regular dependency?
The consumer’s tree can contain two copies. Anything relying on module-level state stops working across that boundary: hooks throw because the dispatcher lives in the other copy, context providers do not match consumers, and instanceof checks against framework classes return false.
How wide should a peer dependency range be?
As wide as you test, with no upper bound. >=18 accepts future majors, which is usually right because most framework majors do not break library integrations. If one does, publish a patch narrowing the range — that affects only new installs.
Does npm install peer dependencies automatically?
npm 7 and later install missing peers when they can satisfy the range, pnpm errors by default, and Yarn warns. Because of that inconsistency a library integrating with an optional peer must feature-detect rather than assume presence.
When is optionalDependencies the right choice?
Only for a package whose absence your code genuinely handles — typically a native accelerator with a pure-JavaScript fallback. An optional dependency the library actually needs fails at runtime rather than at install, which is the worse of the two failures.
Can a package be both a peer and a dev dependency?
Yes, and for a library it usually should be. The peer entry states the requirement; the dev entry gives your own tests and type-checking something to resolve against, and costs consumers nothing.
Related
- Reducing Dependency Weight in Published Packages — the wider classification decision this is one branch of.
- Externalizing Dependencies in Library Bundles — making sure the build honours the choice made here.
- Navigating the Dual-Package Hazard — the same duplicate-instance failure reached through module format instead of dependency resolution.