A18 · Part 3 · Advanced
Migrating and upgrading
Nobody rewrites a working application. The skill is moving it while it keeps shipping.
08On this page
The question you will actually be asked
"We have a four-year-old React app. Two hundred thousand lines, class components in the older half, Redux holding server data, on React 17. How do you modernise it?" The wrong answer is a rewrite. The right answer is an order, a way to measure progress, and a story for the parts you will never touch.
Why the rewrite loses
A rewrite competes with a codebase that is already correct about a thousand edge cases nobody wrote down, while the original keeps moving. You ship nothing for months, then ship something worse. The strangler pattern — new code in the new style, old code migrated when it is touched anyway — keeps the application shippable the whole way.
The order that works
- Get on a supported version first. Everything else is easier from React 18+, and the upgrade itself is usually small.
- Take server state out of Redux. Usually the single biggest win: a query library deletes thousands of lines of actions, reducers and loading flags, and fixes caching you were never going to write.
- Adopt TypeScript file by file, with
allowJson. Never as a big-bang conversion. - Convert classes only when you are already in the file. A working class component is not a bug.
- Then consider the framework move, if there is a real reason for it.
Version by version, what actually breaks
| Upgrade | Breaks |
|---|---|
| 16 → 17 | Almost nothing. Event delegation moved from document to the root container, which breaks code mixing React and native listeners. |
| 17 → 18 | ReactDOM.render → createRoot. Automatic batching now applies in timeouts and promises, so code relying on separate renders changes behaviour. Strict Mode double-invokes effects. |
| 18 → 19 | propTypes and defaultProps removed for function components. String refs gone. forwardRef no longer needed. New JSX transform required. |
| 19.x → 19.2.4+ | Nothing breaks — it is a security floor. Any app using Server Components or Server Functions must be at 19.0.4, 19.1.5 or 19.2.4 or later for React2Shell and the follow-up CVEs, and on a framework release that bundles those fixes. |
| 19.2 → 19.3 | No breaking changes listed, but two behaviour changes can surface old bugs: Strict Mode now double invokes effects during hydration, so a non-idempotent effect in an SSR app shows up; and transitions render independently, so code that assumed two transitions commit together may not. |
npx codemod@latest react/19/migration-recipe // does most of the mechanical work
Run the codemods, read the diff, and do not trust them blindly — they are excellent at renames and poor at judgement.
Class to function, honestly
| Class | Function |
|---|---|
this.state, setState | useState, or useReducer for related fields |
componentDidMount | useEffect(fn, []) |
componentDidUpdate | useEffect(fn, [deps]) |
componentWillUnmount | the cleanup return |
shouldComponentUpdate | memo |
getDerivedStateFromError | still a class — error boundaries have no hook |
The table is the easy half. The trap is that
componentDidUpdate often held logic comparing previous and next
props, and the mechanical translation is an effect that runs at the wrong
times. Ask what the code is synchronising with rather than
transliterating the lifecycle — frequently the answer is that it should
not be an effect at all.
Redux to a query cache
// before: 4 files, ~120 lines, for one endpoint
actions/users.js reducers/users.js selectors/users.js sagas/users.js
// after
const { data } = useQuery({ queryKey: ["users"], queryFn: getUsers });
Do it one slice at a time, keeping both alive. The store stays for genuine client state — and once the server slices are gone, there is often so little left that the store itself can go too.
Adopting Server Components without stopping
- Move to the framework's new router with everything marked
"use client". Nothing changes behaviourally; you now have the door. - Push the directive down the tree, one layout at a time. Each push turns a subtree into server-rendered output.
- Move data fetching from effects into the server components that now exist above them.
- Delete the API routes that existed only so the browser could reach the database.
Each step is shippable on its own and reversible. Measure the client bundle after each one — if it is not falling, the directive is not moving.
Measuring that it is working
| Metric | Tells you |
|---|---|
Files with "use client" | How much is still client-side, as one number |
| Class components remaining | Progress, and it should only fall |
| Client bundle size | Whether the migration is buying anything |
TypeScript coverage, or files still .js | The typed fraction |
Put these in CI. A migration without a number nobody can see is a migration that stalls quietly six weeks in, and the only person who notices is the one who started it.
What to leave alone
Code that works, is not touched, and nobody is asking about. An admin screen three people use, written in classes, with no bugs, is not technical debt — it is finished software. Migrating it costs real time and buys nothing. Say that out loud in the interview; it is the answer that separates someone who has run a migration from someone who has read about one.
Interview answer, one sentence
"Strangler, not rewrite: get onto a supported version, move server state out of Redux into a query cache — usually the biggest single win — adopt TypeScript file by file, and convert classes only when you are already in the file. Put the progress numbers in CI, and leave alone the code that works and nobody touches."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series