Most of the "React Native is hard to maintain" complaints we are called in to fix are not really about React Native. They are about an app that skipped six releases, sat on an unsupported Expo SDK for two years, and now needs a three-month rescue project to ship a one-line change the store is demanding.
Upgrades are cheap when they are routine and expensive when they are events. Here is the cadence and the mechanics we put in place for clients so that staying current costs a day or two per cycle instead of a quarter.
Know the support window you are actually in
Two clocks run at once, and they are not the same clock.
- React Native core ships minor releases every few months, and only a small number of recent minors get patch support. Once 0.82 made the New Architecture the only architecture, staying on an old minor stopped being a neutral choice — it is now a decision to stay on a branch nobody is fixing.
- Expo SDK ships roughly annually-to-biannually and pins a specific React Native version. Each SDK is supported for a limited window; older SDKs lose EAS Build images, classic update support, and — the one that actually bites — the ability to produce a build that satisfies a new Apple or Google target requirement.
The third clock is the platforms themselves: Apple and Google both set annual deadlines for the SDK/target API level you must build against. Those deadlines are non-negotiable, and they are the reason "we'll upgrade next year" turns into an emergency.
Rule of thumb we give clients: never be more than two minors (or one Expo SDK) behind. From there an upgrade is a diff you can read. Four behind, and you are reading a rewrite.
Make the upgrade mechanical
If you are on Expo with CNG
Continuous Native Generation is the single biggest upgrade-cost reducer available. With no checked-in ios/ and android/ folders, an SDK upgrade is mostly:
- Bump the Expo SDK and run the SDK upgrade command to realign dependency versions.
- Run the dependency doctor and fix whatever it flags.
- Delete and regenerate the native projects; build.
Merge conflicts in native project files simply do not exist, because the native projects do not exist in source control. Config plugins carry your native customisations forward. If your app still has checked-in native folders, getting onto CNG is usually the highest-return refactor available before the next upgrade.
If you are on bare React Native
The React Native Upgrade Helper diff between your current version and the target is still the canonical source of truth for native file changes. Work it in this order: Gradle and Android build files, Podfile and iOS project settings, then JS entry points and config. Apply the diff by hand, commit each layer separately, and build between layers so a failure points at one change rather than forty.
Either way, do the upgrade on a dedicated branch with no feature work on it, and get it through CI and a device smoke test before it merges.
Dependency triage is the real work
The framework upgrade is rarely what blocks you. Third-party native modules are. Before you start, inventory every dependency with native code and sort it into four buckets:
- Maintained and already compatible — bump and move on.
- Maintained but lagging — check the repo for an open compatibility PR or a release candidate. Often you can pin a prerelease for a few weeks.
- Replaced by a first-party module — a surprising number of old favourites now have an Expo or core equivalent (media, file system, background tasks, notifications). Migrating to the maintained option during an upgrade is cheaper than migrating later as its own project.
- Abandoned — nothing has merged in a year, issues unanswered. Decide now: fork and patch, vendor a trimmed copy, rewrite the piece you actually use as a small Turbo/Nitro module, or drop the feature. Do not let an abandoned dependency silently pin your entire app to a dead React Native version. That is the single most common root cause of the apps we are asked to rescue.
Use patch-package-style overrides as a bridge, never as a destination, and leave a comment with the upstream issue link and a date.
Defaults that change under you
Upgrades move defaults, and the defaults are where surprises live. On recent versions, expect to re-verify: the New Architecture being on with no opt-out, Hermes-related behaviour and any bytecode/source-map tooling in your crash pipeline, Android edge-to-edge and inset handling, stricter package exports resolution in the bundler, and anything in your build that assumed a specific Gradle, Kotlin, or Xcode version.
None of these are hard individually. They are only hard when six of them arrive at once because you skipped six releases.
Prove it before you ship it
An upgrade is a release like any other, and it deserves more verification than a feature, not less, because the blast radius is the whole app.
- Full CI run: unit tests, React Native Testing Library suites, and an end-to-end smoke flow (login, core task, purchase if you have one) on both platforms.
- Build and install a real release-configuration binary on a physical mid-range Android device, not just a simulator. Release builds catch what debug builds hide.
- Ship to internal testers / TestFlight first, watch crash-free rate and cold-start time against the previous release for a few days, then roll out in stages.
- Never pair a framework upgrade with a risky feature in the same release. When something regresses you want exactly one suspect.
And remember that an over-the-air update channel cannot rescue a bad native upgrade — OTA payloads are tied to a runtime version. The native binary is the thing you have to get right, so keep the previous build ready to re-promote.
Put it on the calendar
The practice that actually works, in one list:
- Quarterly dependency hygiene — a half-day to bump non-native packages, run the doctor, and clear audit noise while it is small.
- One planned framework upgrade per cycle — budgeted and scheduled as its own ticket, not squeezed into a sprint's spare hours.
- An annual store-compliance checkpoint before the platform deadline, not after the rejection email.
- A named owner. Upgrades that belong to "the team" belong to nobody.
A team that does this spends a day or two per cycle. A team that does not spends a quarter, usually at the worst possible moment, with a store deadline attached.
Where we fit
We do this as a standalone engagement all the time: audit the dependency graph, get the app onto CNG, run the upgrade on a branch, hand back a green CI pipeline and a written cadence your team can keep. We also take the recurring slot — one engineer, one planned upgrade per cycle — for teams who would rather it just stayed handled. If your app is more than a couple of releases behind, get in touch and we will tell you honestly how big the gap is before you commit to anything.