EDIT MODE · Ctrl+S to save
react-upgrade.tsx
react-19 utf-8 · ln 1, col 1
1
2
3
4
5
6
7
8
9
10
React Nexus 2026 · Bengaluru

React Upgrade at Scale. The Good, the Bad, and the Unexpected.

01 / 24
agent.session
main · zsh
AI coding-agent · session started
/goal migrate codebase to latest react and make no mistakes
On it! I'll get you to the latest React version — React 19. Confidence: 100% · Estimated time: 2 minutes
build output · 4 hours later
FAIL e2e 364 tests broken
FAIL unit 277 suites · 1000+ tests
WARN deps 16+ incompatible packages
ERROR runtime stale state · infinite spinners · suspense void
Wish it was this easy.
▶ press space to run
02 / 24
upgrade-path.md
react-19 · roadmap
1
2
3
4
5
6
7
8
// the agenda

Upgrades used to be easy. Then concurrent rendering showed up.

React 0.x → 17: bump the version, ship it. Mostly trivial.
React 17 → 18 → 19: concurrent mode, removed APIs, stricter semantics — months of work.
React17
starting point
Where we were. Synchronous rendering. Predictable, blocking, familiar.
React18
the big shift
createRoot activates concurrent rendering. Automatic batching, stricter Suspense & Strict Mode.
React19
no escape hatch
ReactDOM.render removed. Concurrent is the only mode. React Compiler, use(), ref-as-prop.
03 / 24
react-18.tsx
react-18 · strategy
1
2
3
4
5
6
7
8
// React 18 — our approach

The guide says one step. We split it into two.

PHASE 1 Types & Compilation
// Bump package.json to React 18 npm install react@18 react-dom@18 // Fix @types/react 18 breaks npx types-react-codemod preset-18 ./src // Still using ReactDOM.render ReactDOM.render(<App />, root);
Runtime identical to React 17. No behavioral change.
PHASE 2 The Real Work
// Two-line change. Months of work. const root = createRoot(el); root.render(<App />);   → automatic batching → interruptible rendering → stricter Suspense & Strict Mode
Concurrent rendering activates. This is where things break.
Separate them so you don't debug TypeScript errors and concurrent bugs at the same time.
04 / 24
breakage-report.log
react-18 · --all
1
2
3
4
5
6
7
8
// the damage report

A two-line code change. This is what it cost us.

Phase 1 · Types
1000+
of type errors from @types/react 18
Phase 2 · E2E
364
e2e test failures
Phase 2 · Unit
277
test suites broken · 1000+ tests
Phase 2 · Deps
16+
incompatible third-party packages
And then we had to do it all over again for React 19.
05 / 24
types-react.tsx
react-18 · Phase 1
1
2
3
4
5
6
7
8
9
10
// Phase 1 — types & compilation

No runtime changes. Just @types/react 18 breaking.

BREAKS What changes in @types/react 18
// ❌ children no longer implicit on React.FC const Card: React.FC<Props> = (p) =>   <div>{p.children}</div> // ^^^^^^^^ Property does not exist   // ✅ Explicit via PropsWithChildren React.FC<React.PropsWithChildren<Props>>
Also: this.context becomes unknown · useCallback needs explicit types
FIX The codemod handles most of it
# Run the official codemod npx types-react-codemod \   preset-18 ./src   // What it does: // • Wraps FC with PropsWithChildren // • Fixes context types // • Updates deprecated type refs
1000+ type errors → mostly automated. Manual fixes for edge cases.
Phase 1 = types only. App still behaves as React 17. Safe to ship.
06 / 24
eslintrc.json
react-18 · Phase 1
1
2
3
4
5
6
7
8
9
10
// Phase 1 — guard rails

Some hooks import fine. But do nothing without createRoot.

GUARD RAIL Block concurrent features
// .eslintrc — no-ops on legacy root   "no-restricted-imports": [{   name: "react",   importNames: [     "useTransition",     "useDeferredValue"   ] }]
They import fine but do nothing until createRoot.
WORKS NOW Safe in Phase 1
useId() // stable unique IDs useSyncExternalStore() // external stores useInsertionEffect() // CSS-in-JS   // ❌ NOT active yet: // useTransition · useDeferredValue // automatic batching
These work with ReactDOM.render — safe now.
ESLint prevents devs from using concurrent features that silently do nothing.
07 / 24
create-root.tsx
react-18 · Phase 2
1
2
3
4
5
6
7
8
// Phase 2 — the real work begins

Two lines of code. Everything just works. A lot breaks.

THE FLIP createRoot
// Before — synchronous ReactDOM.render(<App />, el);   // After — concurrent const root = createRoot(el); root.render(<App />);
Two-line change. Months of work.
ACTIVATES What kicks in
Automatic batching — state updates in async/promises now batched
Interruptible rendering — React can pause & resume mid-render
Stricter Strict Mode — mount → unmount → remount in dev
Stricter Suspense — lazy components need explicit boundaries
useTransition / useDeferredValue — now actually work
This is where the 364 e2e failures and 277 broken suites came from.
08 / 24
rendering.tsx
react-18 · Phase 2
1
2
3
4
5
6
7
8
// why things break

Synchronous vs Concurrent. React can now pause to respond.

SYNC React 17 — ReactDOM.render
Render A(long, blocking)
👆 user click
blocked — can't respond until render finishes
A long render blocks the user's click until it finishes. The user cannot interact.
CONCURRENT React 18 — createRoot
Render A
⏸ paused
Render A
👆 user click
handled instantly — React paused to respond, then resumed
React pauses render A, handles the click (urgent), then resumes render A. Instant response.
▶ press space for React 17 sync
▶ press space for React 18 concurrent
09 / 24
batching.tsx
react-18 · Phase 2
1
2
3
4
5
6
7
8
// automatic batching

Two setState calls. How many renders?

REACT 17 Synchronous
setTimeout(() => {   setCount(c => c + 1); → render   setFlag(f => !f); → render }, 0);
Renders2
REACT 18 Automatic batching
setTimeout(() => {   setCount(c => c + 1);   setFlag(f => !f);   // batched → 1 render }, 0);
Renders1
Same for promises, native event handlers, and async functions. Existing assumptions break.
▶ press space to reveal the answer
10 / 24
breakages.log
react-18 · Phase 2
1
2
3
4
5
6
7
8
// the breakages we hit

Patterns that worked fine. Until they didn't.

BROKE Stale state from batching
async getData() {   const data = await fetchData();   this.setState({ data });   // ❌ this.state.data is still OLD   doSomething(this.state.data); }
FIX Use the callback value
async getData() {   const data = await fetchData();   this.setState({ data }, () => {     // ✅ use data directly, not state     doSomething(data);   }); }
BROKE Custom lazy loading
// ❌ Recreates lazy on every render function Route({ getComponent }) {   const Lazy = React.lazy(getComponent);   return <Lazy />; // infinite spinner }
FIX Cache the lazy wrapper
const cache = new WeakMap(); function Route({ getComponent }) {   if (!cache.has(getComponent))     cache.set(getComponent, React.lazy(getComponent));   return cache.get(getComponent); }
Plus: Strict Mode broke mount-once assumptions · missing Suspense boundaries caused stuck pages
11 / 24
dep-audit.md
react-18 · Phase 2
1
2
3
4
5
6
7
8
// before you flip to createRoot

Audit your dependencies. 16+ packages weren't ready.

1
Check peer dependency declarations Does the package declare React 18 support? npm ls react or check package.json peerDeps. But don't trust it blindly — some packages lie.
2
Check GitHub issues & discussions Search for "React 18" issues on the package's repo. Real-world breakage reports tell you what peer deps don't.
3
Build a sample page & manually test Ask your AI coding agent to analyze all usage patterns of the package across your codebase. Then create a sample page with mock data covering every pattern. Manually go through each flow — concurrent rendering surfaces issues that static analysis can't.
Missing React 18 peer declaration broken. Always smoke-test first.
12 / 24
jest.setup.js
react-18 · Phase 2
1
2
3
4
5
6
7
8
// unit tests — why they break

DOM doesn't update immediately anymore.

SYMPTOMS What you see
act() warnings everywhere
getBy* failing randomly
Tests pass locally, fail in CI
State updates appearing "late"
After interaction → DOM eventually updates, not immediately.
GATED ROLLOUT Legacy + concurrent in parallel
// jest.setup.js — per folder const LEGACY = ['/legacy/', '/old/'];   const customRender = (ui, o = {}) => {   const isLegacy = LEGACY.some(d =>     expect.getState().testPath?.includes(d));   return RTL.render(ui, {     ...(isLegacy && { legacyRoot: true }),     ...o,   }); };
Incrementally move folders off the legacy list as you fix tests.
RTL supports both roots — you don't have to fix everything at once.
13 / 24
test-fixes.tsx
react-18 · Phase 2
1
2
3
4
5
6
7
8
// the test fix playbook

Four patterns. Fix 1000+ tests.

1
Prefer findBy over getBy
// ❌ breaks — DOM may not have updated screen.getByText("Loaded"); // ✅ waits for element to appear await screen.findByText("Loaded");
2
Use waitFor for state transitions
await waitFor(() => {   expect(button).toBeEnabled(); });
3
Fix fake timers
// RTL uses setTimeout internally const user = userEvent.setup({   advanceTimers: jest.advanceTimersByTime, });
4
Let AI agents do the rest
// document patterns in a skill file // → feed to AI coding agents // → each agent takes a module, makes a PR // → ESLint as guard rail
Document patterns → feed to agents → fix 1000+ tests at scale.
14 / 24
rollout.tsx
react-18 · rollout
1
2
3
4
5
6
7
8
// the rollout

Feature flag. Instant rollback.

CODE Runtime switch
if (isConcurrentModeEnabled) {   const root = createRoot(el);   root.render(<App />); } else {   ReactDOM.render(<App />, el); }
Flag rollback is instant vs git revert + redeploy.
1
Enable for developers locally Strict Mode double-mount (mount→unmount→remount) flags issues in dev itself — before any user traffic
2
Ramp user traffic 0% → 100% Test internally → fix breakages → ramp → merge the package flip PR
Devs catch Strict Mode issues first → then ramp to users with instant rollback.
15 / 24
react-19.tsx
react-19 · migration
1
2
3
4
5
6
7
8
// React 19

No legacy root. No escape hatch.

REMOVED ReactDOM.render
React 19 removes ReactDOM.render entirely. There is no legacy root — concurrent mode is the only mode.
// ❌ gone in React 19 ReactDOM.render(<App />, el);   // ✅ the only way createRoot(el).render(<App />);
IMPLICATION If you skipped Phase 2
If you stayed on ReactDOM.render during React 18, you have to do Phase 2 now.
// RTL legacyRoot won't work either RTL.render(ui, { legacyRoot: true }); // ❌ removed in React 19
Unit tests must run with concurrent root. No gated rollout escape.
React 19 forces concurrent mode. No fallback. The training wheels are off.
16 / 24
react-19-changes.md
react-19 · API changes
1
2
3
4
5
6
7
8
// what changed

Mostly removals and semantic changes.

PropTypes
TypeScript
Runtime prop validation removed. Use TS types.
defaultProps on FCs
Destructure with defaults
const C = ({x = 1}) => ...
String refs
callback / useRef
ref="myRef"ref={useRef()}
useRef() no arg
useRef(required)
useRef()useRef(null)
Global JSX namespace
React.JSX
Import from React, not global.
forwardRef boilerplate
ref as a regular prop
const C = ({ref}) => ...
17 / 24
react-19-compat.ts
react-18 · Phase 1
1
2
3
4
5
6
7
8
9
// React 19 Phase 1 — compat on React 18

Two utilities. Keep types working across 18 & 19 at once.

UTIL 1 mergePropsWithDefaults
// React 19 deprecated defaultProps on FCs // {...defaults, ...props} has a bug: // explicit undefined overwrites defaults   export const mergePropsWithDefaults =   (defaults: Partial<T>, incoming: T): T => {     const result = { ...incoming };     for (const key of Object.keys(defaults)) {       if (incoming[key] === undefined)         result[key] = defaults[key];     }     return result;   };
UTIL 2 — asRef(): casts RefObject<T | null>RefObject<T> for JSX/forwardRef boundaries. Remove when dropping React 18.
GUARD RAILS Custom ESLint rules
// prevent new violations while // migrating to React 19 compat
no-default-props no-prop-types no-string-refs no-legacy-context no-global-jsx-namespace no-deprecated-react-types no-react-dom-render no-react-dom-test-utils no-unmount-component-at-node require-useref-argument require-transition-noderef no-implicit-ref-callback-return
Community rules were limiting — we had to write custom rules.
18 / 24
migration.md
react-19 · strategy
1
2
3
4
5
6
7
8
// our approach

Same strategy. But AI agents this time.

TWO PHASES Again
1
Get code compatible with React 19 while still on React 18. Fix deprecated APIs, add ESLint rules, compat utilities.
2
Flip the package version. Deploy behind traffic split. Ramp to 100%.
SCALING AI agents over codemods
📝Migration skill file — document all patterns (before/after, edge cases)
🤖Feed to AI sub-agents — each takes one module, creates a PR
🛡ESLint as guard rail — catches anything the agent missed
👁Human review — final safety net
AI agents > codemods
19 / 24
rollout-19.tsx
react-19 · rollout
1
2
3
4
5
6
7
8
// rolling out React 19

Can't use a feature flag. Traffic split instead.

WHY NOT Feature flag
React is a build-time dependency. A feature flag would mean two separate builds. The React Compiler also optimizes based on the React version — dual builds are impractical.
SOLUTION Infra traffic split
Load balancer routes traffic between two builds:
95%
React 18 build
5%
React 19 build
100%
→ merge version bump PR
Test internally → ramp 0% → 100% → merge the package flip PR.
20 / 24
react-19-issues.log
react-19 · breakages
1
2
3
4
5
6
7
8
// breakages & ecosystem

React 19 is stricter. And the ecosystem lagged.

RENDERING Less tolerant
A page had an infinite re-render — not caught in 18, broke in 19
Stricter Suspense — missing boundaries that silently worked in 18 now break UX
React is declarative. Most SDKs are imperative. React 19 exposes this mismatch.
ECOSYSTEM Gaps we hit
locator.js stopped working → switched to code-inspector (had to patch it)
React 19.2 recursive prop inspection crashes with cross-origin iframes (reCAPTCHA)
__SECRET_INTERNALS__ removed — libraries relying on it broke instantly
Almost a year later, many packages still don't declare React 19 support
Being on the latest React means you're sometimes ahead of the ecosystem.
21 / 24
react-19-features.md
react-19 · payoff
1
2
3
4
5
6
7
8
// the payoff

What you get for surviving this.

React Compiler
Auto-memoization. No more useMemo, useCallback, or memo.
// just write normal code
ref as a prop
No more forwardRef. ref is a regular prop.
const C = ({ ref }) => ...
use()
Read promises & context directly in render.
const data = use(promise)
useOptimistic
Instant UI feedback during async ops.
const [opt, add] = useOptimistic()
Actions
useActionState for form handling.
const [state, action] = useActionState()
Performance
Faster hydration, reduced re-renders.
// free speedup
22 / 24
takeaways.md
main · summary
1
2
3
4
5
6
7
8
// key takeaways

Five things to take home.

1
Split your upgrades into phases — separate type fixes from behavioral changes
2
Feature flags for runtime changes, traffic splits for build-time changes
3
Invest in ESLint rules to prevent regressions at scale
4
AI agents + skill files scale better than codemods for large migrations
5
Third-party packages are always the wildcard — audit early
23 / 24
thank-you.tsx
main · EOF

Thank you.

Akshay Ashok
SDE II · Web Platform @ Rippling
REACT NEXUS 2026 · BENGALURU
24 / 24