skip to content

In a monorepo, a React Native app importing a shared UI package crashes with an invalid hook call; how does Metro resolution cause it, and how do you fix it?

level: seniorimportance: must knowfreq 44%

answer

  1. two copies of react in one bundle
  2. walk up from the importing file
  3. nested packages/ui/node_modules/react
  4. peerDependency, then dedupe
  5. resolveRequest pins singletons

basics

~20 s

Metro resolves react from each importing file's own folder upwards, so a copy nested in packages/ui/node_modules is bundled beside the app's copy. Make react a peer dependency and dedupe it, or pin react and react-native in resolveRequest or blockList the nested copy.

solid answer

~40 s

Metro resolves a package name relative to the **importing file**, walking `node_modules` up from its folder. App files find the app's or the root's `react`; files in `packages/ui/src` find `packages/ui/node_modules/react` first if the package manager installed a copy there, typically because the UI package lists `react` as a regular or dev dependency with another range. Metro bundles both copies, and the UI package's hooks run against a React instance that is not rendering them: an invalid hook call. The real fix is dependency hygiene: `react` and `react-native` as **peer dependencies** of the shared package, then dedupe. Metro-side guards help too: a `resolveRequest` that resolves them as if imported from the app, or a `blockList` entry hiding the nested copy. `extraNodeModules` does not help, because it is consulted only after the nested copy is found.

code

javascript · 33 lines
javascript
// apps/mobile/metro.config.js (bare React Native app in a workspace)
const path = require('path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');
const singletons = ['react', 'react-native'];

const config = {
  watchFolders: [monorepoRoot],
  resolver: {
    nodeModulesPaths: [
      path.resolve(projectRoot, 'node_modules'),
      path.resolve(monorepoRoot, 'node_modules'),
    ],
    resolveRequest: (context, moduleName, platform) => {
      const isSingleton = singletons.some(
        (name) => moduleName === name || moduleName.startsWith(`${name}/`),
      );
      if (isSingleton) {
        // Resolve as if the app imported it, so packages/ui gets the same copy.
        return context.resolveRequest(
          { ...context, originModulePath: path.join(projectRoot, 'index.js') },
          moduleName,
          platform,
        );
      }
      return context.resolveRequest(context, moduleName, platform);
    },
  },
};

module.exports = mergeConfig(getDefaultConfig(projectRoot), config);

go deeper

for a junior

Recognise that an invalid hook call in a monorepo often means two copies of React, not broken hook code.

for a middle

Explain that Metro resolves from the importing file's folder upwards, so a nested node_modules copy wins for files in that package.

for a senior

Confirm the duplicate, fix it with peerDependencies and dedupe, and add a resolver guard, knowing why extraNodeModules cannot be that guard.

for a principal

Set workspace rules for singleton packages across all apps and decide whether each app's Metro config enforces them.

## The setup and the symptom A workspace holds `apps/mobile` (the React Native app) and `packages/ui` (shared components). The app renders `<Card />` from `packages/ui`, and the screen crashes with React's invalid hook call error, or context providers from the app are invisible inside `Card`. Both are the signature of **two copies of React in one bundle**. The Expo monorepo guide states it bluntly: duplicate React versions in a single app cause runtime errors, and duplicate React Native versions in one monorepo are not supported. ## How Metro ends up bundling two copies Metro resolves a bare import like `react` relative to the file that imports it: 1. For `apps/mobile/App.tsx`, the walk up tries `apps/mobile/node_modules/react`, then `apps/node_modules/react`, then `node_modules/react` at the root. 2. For `packages/ui/src/Card.tsx`, the walk up starts at `packages/ui/src`, reaches `packages/ui/node_modules/react` and stops there if it exists. 3. Metro identifies modules by real file path, so these are two different modules and both go into the bundle. A nested copy usually exists because the shared package lists `react` in `dependencies` or `devDependencies` with a version range the root copy does not satisfy, so the package manager installs a second one inside `packages/ui`. ## Confirming it - Ask the package manager why each copy of `react` is installed and look for two versions. - Check whether `packages/ui/node_modules/react` exists on disk. - Ask Metro directly: wrap `resolveRequest` temporarily, delegate to `context.resolveRequest`, and log the resolved `filePath` for `react` together with `context.originModulePath`. ## Fixes, from root cause to guard rail | Fix | Where | Effect | |---|---|---| | `react` and `react-native` as `peerDependencies` of `packages/ui`, deduped install | package manager | Only one copy exists; the real fix | | `resolveRequest` pinning singletons to the app | Metro config | Every importer gets the app's copy, whatever is installed | | `blockList` entry for the nested copy | Metro config | Metro cannot see it, so the walk continues upwards | | `experiments.autolinkingModuleResolution` | Expo app config | Metro resolves native-module packages to what autolinking links; automatic in monorepos since SDK 55 | | `extraNodeModules` mapping `react` | Metro config | **Does not work**: consulted only after the nested copy is found | ## The resolveRequest guard A custom `resolver.resolveRequest` replaces the default resolver, and inside it `context.resolveRequest` is that default. The guard re-runs the default resolution for `react` and `react-native` (and their subpaths such as `react/jsx-runtime`) with `originModulePath` pointed at a file in the app, so the walk up starts in `apps/mobile` for every importer. All other imports are passed through unchanged. Returned paths must be absolute and real, which delegating to the default resolver guarantees. ## Expo specifics In an Expo SDK 57 workspace, `expo/metro-config` already sets `watchFolders` and `nodeModulesPaths`, so the hand-written block in the example is unnecessary; only the singleton guard is custom. Expo also offers `experiments.autolinkingModuleResolution` in the app config: Metro then resolves packages with native code to the same copy that autolinking compiled into the build. It became opt-in in SDK 54 and is enabled automatically for apps in monorepos since SDK 55, which removes a whole class of JavaScript-versus-native mismatches but does not replace fixing the dependency ranges. ## Why hoisting is not a guarantee Hoisting usually puts one copy at the root, but a single package with an incompatible range reintroduces a nested copy; isolated installs such as pnpm's default do not hoist at all. That is why teams add the Metro-side guard even after fixing `peerDependencies`: it turns a future duplicate into a non-event instead of a runtime crash.

  • Why doesn't mapping react in extraNodeModules fix the duplicate?
    Metro consults `extraNodeModules` only after the hierarchical `node_modules` walk and `nodeModulesPaths` fail. For files in `packages/ui`, the walk finds the nested `react` first and stops, so the map is never read. It only helps for packages that cannot be found at all.
  • Why is a duplicated native package worse than a duplicated JavaScript one?
    A native module is compiled into the app once, from whichever copy autolinking picked. If Metro bundles JavaScript from a different copy, the JavaScript and native halves can disagree. Expo's autolinking-based module resolution makes Metro resolve those packages to the linked copy for exactly this reason.

saying these in an interview costs you the question

  • Blames hooks rules when the bundle has two React copies
  • Adds react to extraNodeModules and expects it to win
  • Lists react in the shared package's dependencies instead of peerDependencies
  • Writes a resolveRequest that never delegates to context.resolveRequest
  • Assumes hoisting always guarantees a single copy