</>CodeWithKarani

React Native Version Mismatch: Rebuilding Is Only Half the Fix

Karani GeoffreyKarani Geoffrey9 min read

You upgraded React Native on Friday. Everything built, the app ran on your phone, you merged. On Monday a tester on an older Android device opens the app and gets a full-screen red error before a single screen renders. You install the same APK on your own device and it works perfectly. Somebody in the channel says "works on my device", and that sentence quietly ends the investigation for three days.

It should not, because "works on my device" is not a statement about the code. It is a statement about that device's install history. This particular error is produced by comparing two artefacts that live in two different places, and one of those places is the phone.

The message tells you two version numbers and then gives you advice about Watchman, which is why almost everybody runs watchman watch-del-all first and almost nobody stops to ask which two things are being compared.

The check compares the React Native version compiled into the native binary on the device against the version in the JavaScript bundle it just loaded. Only major and minor are compared, so a patch difference never causes this.

  • If the two numbers differ, one side is stale. Uninstall the app completely (adb uninstall com.yourapp), do a clean native build, and start Metro with --reset-cache. Reinstalling over the top is not enough.
  • If they are identical and it still fires, you have two copies of react-native resolved in node_modules. Run npm ls react-native.
  • After any upgrade, pin react to the exact version the Upgrade Helper diff shows, not to whatever caret range React Native's peerDependencies will tolerate. Those are two different numbers.
  • Never fix an install error with --legacy-peer-deps or --force. That flag silences the exact check that would have caught this.

The exact error

React Native version mismatch.

JavaScript version: 0.76.5
Native version: 0.74.3

Make sure that you have rebuilt the native code. If the problem persists try
clearing the Watchman cache with `watchman watch-del-all`.

Read those two lines as labels for two build artefacts, not as a puzzle about which version you are on:

  • JavaScript version is baked into the JS bundle Metro just served, or the bundle embedded in a release build.
  • Native version is baked into the compiled binary sitting on the device, generated at native build time.

They can only disagree if the two were produced from different states of the repository. Nothing at runtime changes either number.

Why it happens

React Native runs a version check on startup. It reads a constant compiled into the native side and compares it with a constant in the JavaScript bundle. Crucially, it compares major and minor only. Patch and prerelease components are used to format the message but are excluded from the comparison.

That single detail rules out a whole category of guesses. If you see 0.76.5 against 0.76.1, this error did not fire for that reason and something else is going on. If you see 0.76.x against 0.74.x, you have genuine skew and the only question is which side is old.

The two artefacts the check compares Both are snapshots of node_modules, taken at different moments node_modules/react-native (the only source of truth in the repo) Native binary on the device Version frozen at native build time Lives in the installed APK or IPA Survives npm install. Survives a reboot. JavaScript bundle Version frozen when the bundle is built Served by Metro, or embedded in release Metro caches aggressively by default. Startup: compare major.minor of both. Mismatch throws before any screen renders. Patch and prerelease differences are ignored, so 0.76.5 against 0.76.1 never triggers it.
Neither artefact is rebuilt by an npm install. That is the entire bug, and it is why the device matters.

Why the device with the old app is the one that breaks

Your development phone gets a fresh native build every time you run the app from your machine. A tester's phone has an APK from whenever they last installed one. Install a new APK over the top and Android replaces the code, but a device that has been sideloading builds for months, or that has a release build with an embedded bundle, can end up in states your development loop never reproduces.

So the device-specific pattern is real, but it is not a device bug. It is a bug in what got installed, and the tester's device is the honest one.

The upgrade tooling angle

Historically the fastest route into this mess was react-native-git-upgrade, which rewrote package.json for you using optimistic version ranges. It was removed in React Native 0.59, and anything still calling it is running a tool that has not been maintained for years.

The current advice from the React Native docs is to use the Upgrade Helper diff and then install both packages at the exact versions it shows, with react and react-native moved together. The reason "move them together" matters is that a caret range on react lets npm resolve a version React Native never promised to work with, and the failure surfaces at runtime rather than at install time.

The fix, in numbered steps

Step 1: Find out how many copies of React Native you have

npm ls react-native
npm ls react

You want exactly one version of each, at the top level. Anything like this is your answer:

myapp@1.0.0
├── react-native@0.76.5
└─┬ some-ui-library@2.1.0
  └── react-native@0.74.3

This is the most common cause in a monorepo, where hoisting puts one version at the workspace root and another inside a package. The native build links against whichever path the Gradle or Podfile config resolves, and Metro bundles from whichever one the resolver finds first. They are not required to agree.

Step 2: Understand the two different react versions, then pin the right one

This is where most upgrades go wrong, and it is worth being precise. Ask the package what it will tolerate:

npm view react-native@0.76.0 peerDependencies
{ react: '^18.2.0', '@types/react': '^18.2.6' }

That is a range, not a recommendation. It is the floor below which npm will complain. The version React Native was actually built and tested against is the one pinned in the release template, which for 0.76.0 is react at exactly 18.3.1. The Upgrade Helper diff shows you that pin, and it shows it with no caret, deliberately.

So "npm did not warn me" means only that you are inside the range. It does not mean you are on a tested pair. Install the exact version and commit the lockfile:

npm install --save-exact react@18.3.1 react-native@0.76.0

If a transitive dependency is dragging in a different React, force it. npm 8 and later has overrides; Yarn has resolutions:

{
  "overrides": {
    "react": "18.3.1"
  }
}

Step 3: Clean the native build properly

"Clean" means more than gradlew clean. On Android:

cd android
./gradlew clean
rm -rf build app/build .gradle
cd ..

On iOS, the pod cache and DerivedData both hold compiled artefacts that outlive a normal build:

cd ios
rm -rf Pods Podfile.lock build
bundle exec pod install
cd ..
rm -rf ~/Library/Developer/Xcode/DerivedData

Step 4: Clear the JavaScript side too

watchman watch-del-all
rm -rf node_modules
npm ci
npx react-native start --reset-cache

Use npm ci rather than npm install. npm ci installs exactly what the lockfile says and fails if the lockfile and package.json disagree. npm install is free to re-resolve ranges, which is how you can clean everything and end up with a different mismatch than the one you started with.

Step 5: Remove the old app, do not install over it

adb uninstall com.yourapp
npx react-native run-android

On iOS, delete the app from the simulator or device by hand, or use Device and Simulators to remove the installed build. This is the step people skip, and it is the one that makes the tester's phone match yours.

Verification

Prove the tree is clean before you rebuild, so you are not testing two things at once:

npm ls react-native react
npx react-native info

npm ls should print one version of each with no nested duplicates and no UNMET PEER DEPENDENCY lines. npx react-native info prints the resolved React and React Native versions alongside your toolchain, which is the right thing to paste into a bug report.

Then close the loop where it actually matters, which is CI. Add a check that fails the build when the installed React falls outside the range React Native declares:

RN=$(node -p "require('react-native/package.json').version")
RANGE=$(npm view "react-native@$RN" peerDependencies.react)
HAVE=$(node -p "require('react/package.json').version")

npx --yes semver -r "$RANGE" "$HAVE" > /dev/null || {
  echo "react $HAVE does not satisfy react-native $RN peer range $RANGE"
  exit 1
}
echo "ok: react $HAVE satisfies $RANGE for react-native $RN"

Run that in the same job that runs npm ci, before the native build. It will not catch a version that is inside the range but untested, so treat the Upgrade Helper pin as the real target and this as the backstop. Either way it turns a runtime redbox on a tester's phone into a red pipeline, which is the whole point of having a build pipeline at all.

What people get wrong

Common adviceWhy it fails
watchman watch-del-all and nothing elseIt is in the error message, so everyone runs it first. It clears a file-watching cache on the JavaScript side. It cannot touch the compiled binary on the device, which is the stale artefact more than half the time.
npm install --legacy-peer-deps or --force to get past an install errorThe peer dependency error was the warning. That flag installs a React version React Native never claimed to support, and converts a clear install-time failure into an obscure runtime one. This is the single most damaging habit in this list.
Downgrade React until the redbox stopsSometimes works, always by accident. You now have a version pair nobody tested, and no record of why. Ask the package what it wants instead.
rm -rf node_modules && npm install with no lockfile committedRe-resolves every range from scratch, so two developers on the same commit get different trees. Commit the lockfile and use npm ci.
Use react-native-git-upgradeRemoved in React Native 0.59. It rewrote version ranges optimistically, which is how a lot of these mismatches were created in the first place.
"It works on my device, ship it"Your device has a fresh native build from five minutes ago. The tester's has whatever was installed last month. You are comparing install histories, not code.

When it is still broken

  1. Check what the native build actually linked against. On Android, look at the react-native path resolved in android/settings.gradle and the autolinking output. In a monorepo this often points at a hoisted root node_modules while Metro is bundling from a package-local one.
  2. Check the Podfile path on iOS. The use_react_native! call takes an explicit path to node_modules/react-native. If that path is wrong or stale after a workspace restructure, the pods and the bundle come from different copies.
  3. Confirm the release build is not shipping an old embedded bundle. Release builds bundle JavaScript into the app at build time. If your bundling step is cached in CI or skipped by a stale gradle task, the binary is new and the bundle is old, which is exactly this error with no Metro involved.
  4. Wipe the CI cache. A node_modules cache keyed on something other than the lockfile hash will happily restore last month's tree onto this month's branch. Key it on the lockfile, or do not cache it.
  5. Test on the minimum OS version you claim to support, on a device that has your last released build installed, and upgrade in place. That is what your users will do, and it is the scenario your development loop never runs. Treating that as a required pre-release check is part of what production-grade actually means.

Underneath all of it is one habit worth keeping: when a build tool offers you a flag that makes an error go away without explaining it, that flag is almost always deferring the failure to somewhere more expensive. A failed npm ci costs thirty seconds. A redbox in front of a client costs the meeting.

Frequently asked questions

What does the React Native version mismatch error actually compare?
It compares the React Native version compiled into the native binary installed on the device against the version in the JavaScript bundle that was just loaded. Only the major and minor components are compared, so a patch or prerelease difference never triggers it. If the two numbers differ, one of those two artefacts was built from a different state of your repository.
Why does the error only appear on some devices?
Because one of the compared artefacts lives on the device. Your development phone is reinstalled from a fresh native build every time you run the app, while a tester's phone has whatever APK or IPA was last installed there. That makes the failure look device-specific when it is really specific to install history. Uninstall the app completely rather than installing over the top, then rebuild.
Does watchman watch-del-all fix a React Native version mismatch?
Rarely on its own, even though the error message suggests it. It clears a file-watching cache on the JavaScript side and cannot change the compiled native binary, which is the stale artefact in most cases. Pair it with a full native clean, a Metro start using --reset-cache, and a complete uninstall of the app from the device.
How do I know which react version my React Native version needs?
There are two answers and they differ. npm view react-native@0.76.0 peerDependencies prints the range npm will accept, which is a caret range such as ^18.2.0. The version React Native was actually built and tested against is the exact pin in that release's template, shown in the Upgrade Helper diff. Install that exact version with npm install --save-exact, commit the lockfile, and never use --legacy-peer-deps or --force to get past a peer dependency error.
#React Native#npm#Android#iOS#Metro#Dependencies
Keep reading

Related articles