open atlas
↑ Back to track
React, zero to senior RCT · 14 · 01

React version migrations: the dependency audit is the project

React majors are dependency projects: createRoot flips automatic batching everywhere, StrictMode double-invoke exposes effects that were already wrong, 19 deletes legacy APIs. Audit deps first, codemod the mechanical part, canary-ramp with RUM. Weeks to months at scale.

RCT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The Jira estimate said two weeks: “Upgrade React 17 → 18, swap ReactDOM.render for createRoot, done.” App code took four days — one codemod run, a flushSync wrapper around two scroll-measurement hacks, green build. Then the team ran the test suite: 4,100 enzyme tests, and enzyme has no React 18 adapter — its own maintainer publicly declared the project finished rather than chase React’s new internals. The actual migration was rewriting four thousand tests to Testing Library, retiring an in-house dropdown library built on legacy context, and re-vendoring a chart wrapper that called ReactDOM.findDOMNode. Eleven weeks, of which React itself cost four days. That ratio is the rule, not the exception. A React major is a dependency-graph project wearing a framework costume — and a team that audits its own code first is carefully auditing the cheap part while the expensive part waits in node_modules.

17 → 18: createRoot changes behavior, not just an API

Keeping ReactDOM.render on React 18 runs the app in legacy mode with a deprecation warning and none of the new behavior; switching to createRoot is the actual upgrade — and it ships one silent semantic change that produced a whole class of production bugs: automatic batching everywhere. In 17, multiple setState calls were batched into one render only inside React event handlers; in timeouts, promises, and native event listeners, every setState flushed its own synchronous render. Code quietly depended on that intermediate flush:

// React 17, legacy root: setOpen flushes synchronously here,
// so the modal exists in the DOM by the next line.
setTimeout(() => {
  setOpen(true);
  const h = panelRef.current.offsetHeight; // measures the real panel
  setHeight(h);
}, 0);

// React 18, createRoot: both updates are batched into ONE render.
// panelRef.current is still the OLD tree — offsetHeight reads garbage.
// Escape hatch when you truly need the flush:
flushSync(() => setOpen(true)); // commits before the next line runs

The bug class is subtle because nothing throws: a measurement reads a stale layout, an analytics call fires once instead of twice, a test asserts an intermediate render that no longer exists. flushSync restores 17 semantics for one update at the cost of a synchronous commit — use it for the handful of real measure-after-update cases, not as a blanket fix.

Quiz

After migrating ReactDOM.render to createRoot, a modal positioner that calls setOpen(true) inside a setTimeout and then measures offsetHeight on the next line started returning 0. Nothing throws. What changed?

StrictMode is the migration linter

React 18’s dev-only StrictMode runs every mount as setup → cleanup → setup: effects fire, their cleanup runs, then they fire again. Teams hitting this during migration see doubled WebSocket connections, doubled analytics events, doubled subscriptions — and the tempting diagnosis is “StrictMode broke our app, remove it.” The correct reading is inverted: effects that break under double-invoke were already wrong. Any effect whose cleanup does not perfectly undo its setup misbehaves in production too — on route remounts, on back/forward navigation, and under any future React feature that preserves and restores component state. StrictMode is a fuzzer for the setup/cleanup contract, which makes it the single cheapest migration tool you have: turn it on in dev before the upgrade, fix every doubled side effect it exposes, and you have pre-paid the class of bugs that automatic batching and remounting would have surfaced in production one ticket at a time.

Why this works

Why does React deliberately run effects twice in dev instead of just documenting the contract? Because the contract is unverifiable statically — whether a cleanup truly undoes its setup depends on runtime behavior of arbitrary code (sockets, caches, observers, third-party SDKs). The double-invoke is an executable test of remount-resilience: if mount → unmount → mount produces a correct screen and no leaked resources, your component will survive everything React plans to do with reuse of state. The cost is dev-only noise; the alternative was every team discovering its leaky effects in production, one incident at a time, when reuse-style features land.

React 19: removals, ref as a prop, and the Compiler path

React 19 is the deletion release. APIs that warned for years are gone: defaultProps on function components (use default parameters), string refs, legacy context (contextTypes / getChildContext), propTypes (now ignored entirely), and the old DOM entry points — ReactDOM.render, hydrate, unmountComponentAtNode, findDOMNode. react-test-renderer is deprecated. The one addition that changes daily code: ref is a normal prop on function components, so forwardRef becomes ceremony you can delete:

// 18: wrapper ceremony
const Input = forwardRef((props, ref) => <input ref={ref} {...props} />);
// 19: ref arrives like any other prop
function Input({ ref, ...props }) { return <input ref={ref} {...props} />; }

The React Compiler rides behind 19 as an opt-in build step: it auto-memoizes rule-compliant components, and its adoption path is the same shape as the migration itself — lint first (the expanded eslint-plugin-react-hooks rules tell you which files violate the Rules of React), then enable directory-by-directory rather than big-bang. A component that breaks under the Compiler was, like an effect that breaks under StrictMode, already breaking the rules — the tooling just made it visible.

The process: deps first, codemods honestly, canary with RUM

Dependency audit comes first because dependencies dominate the timeline. Enumerate every package with a react peer dependency and sort them into the death-list categories: test frameworks pinned to React internals (the enzyme funeral — a test framework dying is the single most common hidden cost), libraries built on removed APIs (legacy context, string refs, findDOMNode), and packages whose maintained versions require the new React major while your other deps require the old one. For each, the options are: a compatible major exists (upgrade it), it does not (replace, fork, or delete the feature), or it is internal (budget the rewrite). This audit is a spreadsheet you can build in two days, and it converts “two weeks, probably” into an honest plan.

Codemods cover syntax, not semantics. The official react-codemod collection (plus the React 19 migration recipe) handles mechanical renames — import updates, ReactDOM.rendercreateRoot, string-ref replacement, defaultProps → default parameters. What no codemod fixes: an effect relying on un-batched flushes, a test asserting intermediate renders, a subscription missing cleanup. Run the codemods, then budget real engineering time for the behavioral diff.

Roll out like any risky deploy. Ship the upgraded build to a 1% canary ring with RUM watching three signals against the control ring: JS error rate, hydration-mismatch rate, and INP/LCP p75. Ramp 1 → 10 → 50 → 100 over days; rollback is re-pinning the previous build. Teams that “upgrade on a branch and merge when green” are betting the whole user base on their test coverage; teams that ramp are betting 1% of it.

Together, these four steps — audit, codemods, StrictMode burn-in, canary ramp — form one continuous pipeline: skip the audit and you will discover the death list mid-ramp; skip the canary and a silent batching regression reaches 100% of users before anyone notices.

The cost of staying behind compounds. An old major receives no features and, in practice, no patches; the ecosystem’s peer-dependency ranges move on, so your dependency tree freezes at the last versions supporting your React — including their security fixes. Hiring compounds it: candidates know current React. The honest timeline for a large app is weeks to months, dominated by dependencies — and it grows the longer you wait, because every skipped major adds its own death list.

Quiz

During the 18 burn-in, StrictMode in dev makes the dashboard open two WebSocket connections and duplicate every chat message. A teammate proposes shipping with StrictMode removed since production mounts only once. What is the senior call?

Recall before you leave
  1. 01
    Name the behavior change createRoot introduces, the bug class it produces, and the escape hatch.
  2. 02
    Lay out the migration process in order and justify why dependencies come first.
Recap

A React major upgrade is a dependency-graph project in which your own code is usually the cheap part. Now when you see a React major on the roadmap, run the dependency audit before a single line of code changes — that spreadsheet is the project plan. The 17 → 18 step is dominated by one semantic change — createRoot enables automatic batching outside React event handlers, so code that depended on per-setState synchronous flushes (DOM measurements between updates, render-count-coupled analytics, intermediate-render tests) breaks silently, with flushSync as the targeted escape hatch. StrictMode’s dev-only setup → cleanup → setup cycle is the migration linter: every doubled socket or event it exposes is an effect whose cleanup never matched its setup, which was already a production bug on any remount — fix the cleanup, never remove the linter or guard it away with did-init refs. React 19 deletes the long-deprecated surface — defaultProps on functions, string refs, legacy context, propTypes, the old ReactDOM entry points — and makes ref a plain prop, with the Compiler as an opt-in follow-on whose adoption path is lint-then-enable, directory by directory. The process that survives contact with reality: dependency audit first (death-list categories — dying test frameworks, removed-API libraries, peer-dep conflicts — because deps, not code, set the timeline), codemods for the mechanical renames with honest acknowledgment that they fix syntax and never semantics, StrictMode burn-in, then a canary ramp with RUM watching error rate, hydration-mismatch rate, and INP p75 per ring, with rollback as a re-pin. Staying behind is not neutral: frozen peer-dependency trees stop receiving security fixes, and every skipped major adds its own death list. For a large app, plan weeks to months — and know that almost none of it is React.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.