Upgrading React is usually less work than teams fear and less trivial than the release notes suggest. The failures we get called about are rarely React itself — they are a stale dependency, a StrictMode behavior that exposes an existing bug, or a big-bang upgrade attempted in one branch over a weekend. This is the checklist we run, in order.
Before you touch React
Get the test suite trustworthy first. An upgrade is a large behavioral diff; without tests you can believe, you are relying on manual clicking to catch regressions. If coverage on critical flows is thin, add it before the upgrade — that work is valuable regardless of what React version you land on.
Audit the dependency tree. The blocker is almost never React. It is a UI library, a router, a testing library, a state manager, or an animation library pinned to an older React. List every package with a React peer dependency, check each one's support matrix, and identify which ones need their own upgrade first. Do those upgrades as separate, mergeable pull requests. Abandoned packages need a replacement plan, and that plan is the real critical path.
Upgrade one major at a time. 17 → 18, then 18 → 19. Each step is understandable in isolation; the combined diff is not.
React 17 → 18
createRoot. Replace ReactDOM.render with createRoot(container).render(...). This is the switch that enables concurrent features and automatic batching. TypeScript users: @types/react 18 made children no longer implicit in React.FC, so components relying on it need explicit children props — a large but mechanical diff, and a codemod handles most of it.
Automatic batching. In 18, state updates batch everywhere — including in promises, timeouts, and native event handlers, where 17 flushed them individually. This is a performance improvement that occasionally changes behavior for code that read state between two updates in the same tick. Where you genuinely need a synchronous flush, flushSync exists; needing it more than once or twice is a smell.
StrictMode double-invocation. In development, React 18 mounts, unmounts, and remounts components, running effects twice. This is not a bug and does not happen in production — it surfaces effects that were never idempotent. Every "React 18 broke our app" report we have investigated turned out to be an effect missing its cleanup: a subscription not unsubscribed, an interval not cleared, an analytics event fired on mount without a guard. Fix the effects; do not disable StrictMode.
Adopt concurrent features deliberately, later. useTransition, useDeferredValue, and streaming SSR are opt-in. Land the upgrade first with behavior unchanged, then adopt them where a measurement says they help.
React 18 → 19
Smaller than the 17→18 step for most codebases, but with real cleanup:
- Deprecated APIs removed. Legacy string refs,
propTypesanddefaultPropson function components, legacy context, andReactDOM.render/findDOMNodeare gone. Codemods cover the common cases; run them, then grep for stragglers. refas a prop. Function components now receiverefdirectly, andforwardRefis no longer required. ExistingforwardRefcode still works — do the cleanup opportunistically rather than as a blocking task.- New hooks and actions.
use,useActionState,useOptimistic, and form actions are additions, not migrations. Adopt them where they simplify code you are already touching. - Better hydration errors. Mismatch messages now show an actual diff, which usually turns an old, unexplained hydration warning into a five-minute fix. Budget time to finally close those.
- The React Compiler. Available separately, opt-in per directory. Treat it as its own project after the upgrade lands: enable it in a bounded area, verify with the Profiler, and remove hand-written memoization only where you can show the compiler covers it.
How to run the upgrade
One long-lived upgrade branch is how these die. Instead: land every dependency prerequisite on main first, then do the React bump itself in one focused pull request with the mechanical codemods applied and the test suite green. Ship it to a staging environment and exercise the flows tests cannot cover — anything touching third-party widgets, iframes, portals, or non-React DOM manipulation.
Then watch production. Client-side error rates and the INP metric are the two signals worth staring at for a week; concurrency changes show up there before anyone files a ticket.
Done this way, most upgrades are a handful of days across the whole sequence — and the codebase comes out of it with a healthier dependency tree and a set of effects that were quietly broken before anyone looked.