A consumer reports a failure that reproduces in their application and not in your test suite. The instinct is npm link, and it usually works — but it introduces its own artefacts, some of which look exactly like the bug you are chasing:

Warning: Invalid hook call. You might have more than one copy of React in the same app.

That warning appeared because of the link, not because of the bug. This guide covers what a link actually changes, when its divergence matters, and the two alternatives that reproduce a consumer’s environment more faithfully.


Root Cause

npm link creates a symlink in the consuming project’s node_modules pointing at your library’s working directory. Three consequences follow, and each can either help or mislead.

The link exposes the whole directory, not the packed subset, so files excluded by files are reachable and the exports map is evaluated against a tree containing source, tests and configuration. The link brings its own node_modules, so any package installed in your library — including dev-dependency copies of frameworks — resolves from there rather than from the application. And the link is live, so a rebuild is visible immediately, which is the reason to use it in the first place.


Minimal Reproduction

cd ~/work/my-library && npm link
cd ~/work/consumer-app && npm link @scope/my-library

node -e "console.log(require.resolve('@scope/my-library'))"
node -e "console.log(require.resolve('react'))"
node -e "console.log(require.resolve('react', { paths: [require.resolve('@scope/my-library')] }))"
/home/dev/work/my-library/dist/index.cjs
/home/dev/work/consumer-app/node_modules/react/index.js
/home/dev/work/my-library/node_modules/react/index.js      ← a second copy

The third line is the artefact. Nothing about the consumer’s real install produces it.


Linked versus installed A linked package exposes the whole working directory, carries its own node_modules, and updates live. An installed tarball exposes only packed files, resolves dependencies through the application's tree, and requires a reinstall to update. Fast feedback, at the cost of fidelity npm link (symlink) sees: the whole working directory deps: its own node_modules first updates: instantly on rebuild good for: iterating on a fix bad for: proving the fix ships pack and install sees: exactly the packed files deps: the application's tree updates: on reinstall only good for: reproducing reality bad for: a fast edit loop

Step-by-Step Fix

1. Remove the duplicate-instance artefact

Before trusting anything a link tells you, make sure shared packages resolve to the application’s copy.

# in the library, remove dev copies of anything declared as a peer
node -e '
  const peers = Object.keys(require("./package.json").peerDependencies ?? {});
  console.log("remove from node_modules while linked:", peers.join(", "));
'
rm -rf node_modules/react node_modules/react-dom

An alternative that survives reinstalls is to alias the shared package in the application’s bundler config so both paths resolve to one copy:

// vite.config.ts, in the consuming app, while debugging
export default defineConfig({
  resolve: {
    dedupe: ["react", "react-dom"],
    alias: { react: path.resolve("./node_modules/react") },
  },
});

A link is the right tool for the loop: change source, rebuild, re-run the consumer’s reproduction, repeat. Keep the build running in watch mode so the loop is a single keystroke:

cd ~/work/my-library && npx tsup --watch

3. Switch to pack-and-install before believing the fix

cd ~/work/my-library
npm pack --silent            # produces scope-my-library-1.4.1.tgz

cd ~/work/consumer-app
npm uninstall @scope/my-library    # drops the link
npm install ~/work/my-library/scope-my-library-1.4.1.tgz
npm run reproduce

This step catches the class of bug a link structurally cannot: a fix that lives in a file excluded from files, an exports subpath that points at something the build does not emit, a postinstall script that fails outside your repository.

4. Or use a local store for a middle ground

Three methods ordered by fidelity A symlink gives the fastest loop and the least fidelity. A local store such as yalc copies packed contents and gives most of the fidelity with a fast loop. Installing the packed tarball gives full fidelity and the slowest loop. Speed and fidelity trade off in one direction npm link loop: instant fidelity: lowest artefacts: duplicate deps, unpacked files visible local store (yalc) loop: one command fidelity: high copies packed contents into a real directory pack + install loop: slowest fidelity: exact the only method that proves what ships
npx yalc publish          # in the library
npx yalc add @scope/my-library   # in the consumer
# after a change:
npx yalc push

HAZARD PREVENTION

Symptom: A bug disappears when the package is linked and returns after publishing.

Root cause: The fix depends on a file that the link exposes and the tarball does not — typically a source file reached through an exports path that resolves in the working directory because src/ is present there.

Fix: Reproduce with pack-and-install before concluding a fix works, and check npm pack --dry-run for the file the fix touches.


Verification

# the linked package resolves to your working directory
node -e "console.log(require.resolve('@scope/my-library'))"

# there is exactly one copy of each peer
npm ls react react-dom 2>&1 | grep -c "react@" 

# the same reproduction passes against the packed tarball
npm install /path/to/scope-my-library-1.4.1.tgz && npm run reproduce

Expected: a path inside your library, a single copy of each peer, and a passing reproduction against the tarball rather than the link.


Edge Cases / Gotchas

  • npm link and workspaces interact badly. Inside a monorepo the package may already be symlinked by the workspace, and adding a global link creates a second, competing link with unclear precedence.
  • pnpm’s link behaves differently from npm’s. Its strict layout means the linked package cannot reach the application’s dependencies at all, which surfaces missing peers that npm’s hoisting hides.
  • Unlinking is easy to forget. A stale global link silently shadows the published package in every project that ever linked it; npm ls --link --global lists them.
  • TypeScript resolves through the link too. Types come from your working directory, so a declaration error introduced but not yet built is invisible until the tarball path is exercised.
  • Watch-mode rebuilds can be read mid-write. A consumer process may load a partially written file; a build that writes atomically, or a short delay before re-running, avoids chasing a phantom failure.

Frequently Asked Questions

Why does npm link produce duplicate React or other framework copies?

A linked package is a symlink to your working directory, and its own node_modules travels with it. When the framework is installed there as a dev dependency, the linked copy resolves to it while the application resolves to its own — two instances.

Does npm link test what a consumer will actually receive?

No. A link exposes the whole working directory, so excluded files are reachable and the exports map is applied against a tree containing source and tests. Pack-and-install is the only faithful method.

What is yalc and when is it better than a link?

It copies the packed contents into a local store and installs from there, giving a real directory containing only publishable files — most of the fidelity of pack-and-install with a fast loop.

Why does my linked package fail to resolve its own exports subpaths?

Because the map points at build output that may not exist yet in the working directory. With an installed tarball the build has already run, so the failure cannot occur.

Should linking be part of the release checklist?

No. It is a development loop. The release checklist should use pack-and-install, because the questions it answers are exactly the ones linking cannot.



↑ Back to Source Maps and Debugging Published Packages