CORE JSC

International Technology Partnership

React Native

Fixing Metro Bundler Resolving the Wrong Module in a React Native Monorepo

A fix lands in a shared package's source and the running app keeps behaving as if it never happened — or worse, React throws "Invalid hook call" for no obvious reason. Neither is a logic bug; Metro is bundling a different physical copy of the module than the one that was actually edited.

Core JSC Team·September 9, 2026
React NativeMetroMonorepoModule ResolutionDeveloper Tools

The Problem

In a monorepo containing a React Native app alongside shared internal packages (and possibly a web app too), a fix made to a shared package's source doesn't seem to take effect in the running app — the old behavior persists no matter how many times the change is verified in the file itself. In other cases, the app throws a React error like "Invalid hook call," which normally points at a real hooks-rule violation, but the app's own code looks completely correct. Neither symptom reproduces in a fresh, single-package project with a clean npm install.

Why It Happens

A monorepo can genuinely have more than one physical copy of the same package on disk

With hoisted or deduped node_modules structures (common with npm/yarn workspaces or pnpm) combined with symlinked local packages, more than one actual copy of a package — react, or a shared internal utility package — can exist simultaneously on disk. Metro has to decide, for each import, which copy to actually resolve to, and its resolution order doesn't always land on the one a developer assumed was the only one installed.

Metro's Haste module system can collide across packages in ways standard resolution wouldn't

Separate from ordinary node_modules resolution, Metro's Haste system resolves modules by name across the entire watched file tree. In a monorepo, two different packages can end up exporting something under a colliding internal name, and depending on watch and cache state, an edit intended for one package's file can effectively get attributed to bundling as though it belonged to the other.

Metro's aggressive caching can make a real source change appear to not apply at all

Metro caches heavily for performance, and a change to a shared package's source — one that should take effect immediately in a normal monorepo dev workflow — can appear to have no effect whatsoever if the cache wasn't properly invalidated for that specific file, a state especially common right after switching git branches or after a symlink target has changed underneath an already-running Metro process.

Default Metro configuration is tuned for a single package, not a monorepo

Metro's default watchFolders and resolver settings assume a single-package project. A monorepo genuinely needs watchFolders extended to actually cover sibling packages, and usually nodeModulesPaths/extraNodeModules configured explicitly — a project that never customized these can appear to "mostly work" day to day while silently resolving stale or duplicate copies for specific edge cases that only surface intermittently.

The Fix

1. Explicitly extend watchFolders to cover the monorepo's sibling packages

// metro.config.js
const path = require("path");

module.exports = {
  watchFolders: [
    path.resolve(__dirname, "../shared-package"),
    path.resolve(__dirname, "../another-shared-package"),
  ],
};

Without this, Metro doesn't actually watch and rebuild against a sibling package's real source directory — a fix made there can genuinely fail to reach the bundler at all, independent of any caching issue.

2. Make module resolution order explicit rather than relying on default hoisting

// metro.config.js
module.exports = {
  resolver: {
    nodeModulesPaths: [path.resolve(__dirname, "node_modules")],
    extraNodeModules: {
      "shared-package": path.resolve(__dirname, "../shared-package"),
    },
  },
};

Configuring resolution paths explicitly, rather than trusting that the package manager's hoisting happened to land on the intended copy, removes the ambiguity that lets Metro pick a different physical copy than the one actually being edited.

3. Clear Metro's cache explicitly before assuming a code change is wrong

npx react-native start --reset-cache

When a shared-package edit doesn't seem to take effect, or a "two React instances" style error appears, resetting Metro's cache first — before spending time re-verifying the source change itself — resolves one of the most common actual causes of this exact symptom, especially after a branch switch or a symlink change.

4. Deduplicate genuinely shared dependencies to a single resolved version

yarn dedupe
# or, for npm:
npm dedupe

Reducing react, react-native, and shared internal packages to a single resolved copy across the whole monorepo, where the package manager supports it, removes the underlying condition Metro has to arbitrate in the first place — there's no ambiguity to resolve incorrectly if only one copy genuinely exists.

Why This Works

Each fix closes a specific gap between what a monorepo's file structure actually contains and what Metro's default, single-package-oriented assumptions expect. Extending watchFolders ensures sibling package source is actually watched; explicit resolver configuration removes reliance on hoisting behavior landing on the right copy by chance; clearing the cache addresses a genuinely common, easily overlooked cause of "the fix isn't taking effect"; and deduplicating dependencies removes the root condition — multiple physical copies — that made resolution ambiguous to begin with.

Conclusion

Metro bundling the wrong module in a monorepo — a stale copy of a shared package, or a duplicate React instance triggering hook errors — is almost always a monorepo-specific resolution or caching gap, not a logic bug in the edited source. Extend watchFolders to cover sibling packages, make resolver paths explicit rather than relying on default hoisting, reset Metro's cache before assuming a code change is wrong, and deduplicate genuinely shared dependencies to a single resolved copy across the monorepo.