The first three posts built the model: how React renders, how hooks and state fit together, and how concurrent React schedules work. This last core post is about keeping a large React codebase fast, correct and usable as it grows. It starts with measurement — the Profiler, DevTools and React 19.2 Performance Tracks — because most React performance work fails by fixing the wrong thing. Then it covers the fixes in order of how often they matter: fewer renders (memoisation, with and without React Compiler), less rendered (virtualisation), less shipped (code splitting). The second half covers the parts that keep a big app healthy: error boundaries, a testing strategy with Vitest, React Testing Library and Playwright, accessibility, and the architecture of reusable component libraries — compound components, headless UI and polymorphism. Examples target React 19.2.

Series map: 00 Rendering Model → 01 Hooks & State Architecture → 02 Concurrent React & React 19 → 03 Performance, Testing & Architecture at Scale (this post). Future topics: see the "Planned future extensions" table at the end of this post.


Learning Outcome

  • Profile a slow interaction and classify the cause before changing code.
  • Read the React DevTools Profiler (flame graph, ranked view, "why did this render") and the <Profiler> API.
  • Decide when memo, useMemo and useCallback pay off, and what changes with React Compiler.
  • Virtualise long lists and split code by route and by interaction.
  • Place error boundaries so one failure does not blank the whole app, and report errors from the root.
  • Build a test strategy: Vitest + React Testing Library for components, MSW for the network, Playwright for end-to-end.
  • Catch common accessibility failures with semantic HTML, focus management, linting and axe.
  • Design reusable components with compound components, headless hooks, controlled/uncontrolled props and polymorphism.

Chapter 1 — Measure first

Terminology

  • Render time — time spent calling components and reconciling (the render phase, Post 00).
  • Commit time — time spent applying DOM changes and running layout effects.
  • actualDuration — time spent rendering a subtree for this commit, after memoisation.
  • baseDuration — estimated time to render the whole subtree with no memoisation. The gap between the two shows how much memoisation is saving.
  • INP (Interaction to Next Paint) — the Core Web Vital for responsiveness since March 2024. "Good" is 200 ms or less at the 75th percentile.

The workflow

  1. Reproduce the slow interaction with a production-like build and realistic data. Development builds are several times slower and StrictMode doubles renders; do not judge speed there.
  2. Record it: React DevTools Profiler for components, the browser Performance panel for everything else.
  3. Classify the cost (table below).
  4. Fix the one thing the profile points at.
  5. Profile again. A fix that does not move the number is reverted.
Symptom in the profile Cause Fix
Many components render with the same output Too many renders Move state down, pass children, split context, memoise
One component renders slowly on every change Expensive render useMemo the calculation, useDeferredValue / Transition, move work off render
Commit is long, huge DOM Too much rendered Virtualise, paginate, content-visibility
Long "Evaluate script" before first interaction Too much JavaScript Code splitting, lighter dependencies, Server Components
Long tasks from non-React code Third-party scripts, layout thrashing Out of React's scope — fix in the Performance panel

Performance workflow: reproduce, record, classify, fix, profile again. Each symptom in the profile maps to a cause (too many renders, expensive render, too much rendered, too much JavaScript, or third-party scripts) and to its fix, from moving state down and memoising to useDeferredValue, virtualisation, code splitting, and the Performance panel for work outside React.

React DevTools Profiler

  • Flame graph — each bar is a component in one commit; width is render time, colour shows how long it took relative to others; grey means it did not render.
  • Ranked — components sorted by render time for one commit.
  • Settings → "Record why each component rendered while profiling" — shows "Props changed: (onSelect)", "Hook 2 changed", "Parent component rendered". This one setting answers most "why is this rendering?" questions.
  • "Highlight updates when components render" — flashes a border around rendering components while you use the app.

<Profiler> in code

1
2
3
4
5
6
7
8
9
10
11
12
import { Profiler } from "react";

function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
if (actualDuration > 16) {
metrics.record("react.slow_render", { id, phase, actualDuration, baseDuration });
}
}

<Profiler id="OrdersTable" onRender={onRender}>
<OrdersTable />
</Profiler>

phase is "mount", "update" or "nested-update". Profiling is disabled in the normal production build; to measure in production, build with the profiling bundle (react-dom/profiling), which adds a little overhead.

Performance Tracks (React 19.2)

React 19.2 adds custom tracks to the Chrome Performance panel: Scheduler (work by priority — blocking, transition — Post 02) and Components (which components rendered and ran effects). They put React's view next to network, layout and paint on one timeline, which is the fastest way to see whether a long task is React work at all.

Watch out: "it re-renders a lot" is not a performance problem until it shows up in a profile as time the user waits for. A component that renders 50 times in 0.1 ms each is fine; one that renders once in 180 ms is not.


Chapter 2 — Memoisation, with and without the compiler

The three tools

Tool Caches Pays off when
memo(Component) The render of a component, if props are Object.is equal The component is expensive and its parent renders often with the same props
useMemo(fn, deps) A computed value The calculation is measurably slow (roughly 1 ms or more), or the value's identity matters (a memo child prop, an effect dependency)
useCallback(fn, deps) A function's identity The function is passed to a memo child or used as an effect dependency

They are a chain: useCallback alone does nothing useful unless the receiver is memo or uses the function as a dependency. And one unstable prop anywhere breaks memo:

1
2
3
4
5
6
7
8
9
const Chart = memo(function Chart({ data, options, onPointClick }) { /* slow */ });

function Dashboard({ rows }) {
const data = useMemo(() => aggregate(rows), [rows]); // stable until rows change
const onPointClick = useCallback(p => openDetails(p.id), []); // stable forever
return <Chart data={data} options={CHART_OPTIONS} onPointClick={onPointClick} />;
}
const CHART_OPTIONS = { animate: false }; // module constant: always stable

Before memoising, try the structural fixes from Post 00 — they cost nothing at runtime and cannot go stale:

  • Move state down into the component that uses it, so its siblings stop rendering.
  • Lift content up into children, so a stateful wrapper does not re-render its content.
  • Split contexts so a fast-changing value does not re-render every consumer.

With React Compiler

React Compiler 1.0 (Post 02) memoises JSX, objects, callbacks and computations automatically, at a finer grain than hand-written hooks. In a compiled codebase:

  • New code rarely needs memo, useMemo or useCallback. Write plain code.
  • Keep manual memoisation where a value's identity is part of correctness — an effect dependency that must not change — so the intent is explicit.
  • Do not bulk-delete existing memoisation; the React team advises leaving it or removing it carefully with tests, because removal can change the compiled output.
  • Components the compiler skips (Rules of React violations) get no help. The eslint-plugin-react-hooks compiler rules list them; fixing them is usually the real performance work.
  • The compiler does not fix expensive algorithms, huge DOM trees or big bundles. Chapters 3 and 4 still apply.

Common pitfall: useMemo(() => items.filter(...), [items]) where items is rebuilt by the parent on every render. The memo recomputes every time and adds overhead. Memoisation has to start at the source of the value.


Chapter 3 — Rendering less: virtualisation

A list of 10,000 rows creates 10,000 sets of DOM nodes. Even if React renders them quickly, the browser must lay them out and keep them in memory, and every commit that touches the list is slow. Virtualisation renders only the rows in (or near) the viewport and uses spacer height for the rest.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

function OrderList({ orders }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: orders.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // px; measured sizes replace it if you pass measureElement
overscan: 8, // extra rows above and below for smooth scrolling
});

return (
<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map(row => (
<div
key={row.key}
style={{ position: "absolute", top: 0, left: 0, width: "100%",
height: row.size, transform: `translateY(${row.start}px)` }}
>

<OrderRow order={orders[row.index]} />
</div>
))}
</div>
</div>

);
}

Trade-offs to plan for:

  • Find in page (Ctrl+F) cannot find rows that are not rendered.
  • Accessibility: screen readers see only rendered rows. Set aria-rowcount / aria-rowindex on grids, or offer pagination.
  • Variable heights need measurement, which can cause scroll jumps.
  • Scroll restoration needs the virtualiser's offset, not window.scrollY.

A lighter option for long content pages (not data grids) is the CSS property content-visibility: auto with contain-intrinsic-size: the browser skips layout and paint for off-screen sections while keeping them in the DOM (and so in find-in-page and the accessibility tree). It saves browser work, not React render work.


Chapter 4 — Shipping less: code splitting with lazy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { lazy, Suspense } from "react";

const SettingsPage = lazy(() => import("./SettingsPage")); // default export
const Chart = lazy(() => import("./Chart").then(m => ({ default: m.Chart }))); // named export

function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>

);
}

Rules and techniques:

  • Declare lazy at module level. Calling lazy inside a component creates a new component type every render — the remount bug from Post 00.
  • Split by route first, then by heavy interaction-only features (editors, charts, maps, date pickers).
  • Preload on intent: start the import() on hover or focus of the link, so the chunk is ready at click time. Keep the loader function in a variable and call it in both places; the module cache makes the second call free.
  • Navigate in a Transition so the current page stays visible while the next chunk loads (Post 02), instead of showing the top-level fallback.
  • Handle chunk load failures. After a deploy, old HTML can ask for chunk files that no longer exist. Put an error boundary around lazy routes that offers a reload, and keep old chunks available for a while if the hosting allows.

Trick: check what actually ships with a bundle analyser (for example rollup-plugin-visualizer for Vite). One date or icon library imported the wrong way often outweighs all of your own components.


Chapter 5 — Error boundaries

An error thrown during rendering, in a lifecycle method, or in an effect of a component unmounts the whole root unless an error boundary above it catches it. Error boundaries are still class components (there is no hook equivalent in React 19.2):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ErrorBoundary extends React.Component {
state = { error: null };

static getDerivedStateFromError(error) {
return { error }; // render the fallback next
}

componentDidCatch(error, info) {
reportError(error, { componentStack: info.componentStack });
}

render() {
if (this.state.error) return this.props.fallback(this.state.error, () => this.setState({ error: null }));
return this.props.children;
}
}

Most teams use the react-error-boundary package, which provides the same component with FallbackComponent, onReset and resetKeys.

What error boundaries do not catch:

  • errors in event handlers (use try/catch, or show the error through state),
  • errors in async code that is not part of rendering (setTimeout, a promise in a handler),
  • errors in the boundary itself,
  • errors during server rendering.

Promises rejected through use() and errors thrown by Actions (Post 02) do reach the nearest boundary.

Placement

  • Root: a last-resort "something went wrong" page with a reload button.
  • Per route: one broken page does not take the navigation down.
  • Per widget for independent panels (a dashboard card, a third-party embed).
  • Reset on navigation: pass resetKeys={[location.pathname]} (or key the boundary) so moving away clears the error.

Root error reporting (React 19)

1
2
3
4
5
6
const root = createRoot(container, {
onUncaughtError: (error, errorInfo) => reportError(error, errorInfo), // no boundary caught it
onCaughtError: (error, errorInfo) => reportError(error, errorInfo), // a boundary caught it
onRecoverableError: (error, errorInfo) => log(error), // e.g. hydration mismatch recovered
});

These give one place to send errors to monitoring, with the component stack in errorInfo.


Chapter 6 — Testing strategy

What to test where

Layer Tool Tests
Static TypeScript, ESLint (react-hooks, jsx-a11y) Types, Rules of Hooks, obvious a11y errors
Unit Vitest Reducers, selectors, formatters, custom hooks (renderHook)
Component / integration Vitest + React Testing Library (+ MSW) A component or a screen, with real children and a mocked network
End-to-end Playwright Critical user journeys in real browsers against a running app

Most confidence per minute of effort comes from the component/integration layer: render a real screen, interact like a user, assert on what the user sees.

Vitest + React Testing Library

1
2
3
4
5
6
7
8
9
10
11
12
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [react()],
test: { environment: "jsdom", globals: true, setupFiles: ["./vitest.setup.ts"] },
});

// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

const server = setupServer(
http.post("/api/subscribe", () => HttpResponse.json({ ok: true }))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test("subscribes with a valid email", async () => {
const user = userEvent.setup();
render(<Newsletter />);

await user.type(screen.getByRole("textbox", { name: /email/i }), "ada@example.com");
await user.click(screen.getByRole("button", { name: /subscribe/i }));

expect(await screen.findByRole("status")).toHaveTextContent("Subscribed ada@example.com");
});

Principles that keep these tests valuable:

  • Query like a user. Prefer getByRole with an accessible name, then getByLabelText, getByText; use getByTestId last. Role queries double as accessibility checks: if the test cannot find the button by role and name, neither can a screen reader.
  • Use user-event, not fireEvent: it simulates the full sequence of browser events (focus, keydown, input, keyup).
  • Wait for async UI with findBy… or waitFor, not with timers.
  • Mock the network, not your modules. MSW intercepts requests, so the component, its hooks and the data library all run for real.
  • Do not test implementation details — internal state, hook call counts, component names. Tests that survive refactors are the ones worth keeping.

For components that depend on real layout, CSS or browser APIs that jsdom lacks, Vitest's Browser Mode (stable since Vitest 4.0) runs the same tests in a real browser through Playwright or WebdriverIO.

Playwright for end-to-end

1
2
3
4
5
6
7
8
9
10
import { test, expect } from "@playwright/test";

test("checkout happy path", async ({ page }) => {
await page.goto("/products/42");
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByRole("link", { name: "Cart (1)" }).click();
await page.getByRole("button", { name: "Checkout" }).click();
await expect(page.getByRole("heading", { name: "Order confirmed" })).toBeVisible();
});

Playwright's locators auto-wait for elements to be actionable, and expect(...).toBeVisible() retries until a timeout, so tests do not need sleeps. Keep the E2E suite small — login, checkout, the flows that earn money — and run it on every merge in Chromium, Firefox and WebKit.

Common pitfall: snapshot tests of whole component trees. They fail on every markup change, reviewers approve the new snapshot without reading it, and they catch almost nothing. Assert on behaviour instead.


Chapter 7 — Accessibility

Accessibility problems in React apps are mostly the same few mistakes, repeated in shared components and so multiplied across the app.

Mistake Fix
<div onClick> as a button Use <button>; it gives focus, Enter/Space and the role for free
Inputs without labels <label htmlFor> or wrap the input; aria-label only when there is no visible text
Custom dropdowns, tabs, dialogs built from divs Follow the ARIA Authoring Practices pattern, or use a headless library that does (Chapter 8)
Focus lost after route change or dialog close Move focus to the new page heading; return focus to the trigger when a dialog closes
Async results not announced A polite live region (role="status") for results and toasts
Motion that ignores user settings Respect prefers-reduced-motion
Duplicate ids from reused components useId() for htmlFor / aria-describedby pairs
1
2
3
4
5
6
7
8
9
10
11
12
function Field({ label, error, ...inputProps }) {
const id = useId();
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} aria-invalid={!!error} aria-describedby={error ? errorId : undefined} {...inputProps} />
{error && <p id={errorId} role="alert">{error}</p>}
</div>

);
}

Automate what can be automated: eslint-plugin-jsx-a11y at build time, and axe in tests — @axe-core/playwright in E2E:

1
2
3
4
5
6
7
8
import AxeBuilder from "@axe-core/playwright";

test("settings page has no detectable a11y violations", async ({ page }) => {
await page.goto("/settings");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});

Automated tools find only part of the problems. Keyboard-only walkthroughs and a screen-reader check of the key flows are still required.


Chapter 8 — Component-library architecture

Terminology

  • Compound components — a set of components that work together and share state through context: <Tabs>, <Tabs.List>, <Tabs.Tab>, <Tabs.Panel>.
  • Headless component — behaviour, state and accessibility without styling, delivered as hooks or unstyled components (Radix UI Primitives, React Aria, Headless UI, TanStack Table).
  • Controlled / uncontrolled — the parent owns the value (value + onChange) or the component does (defaultValue).
  • Polymorphism — one component that can render as different elements (<Button as="a">) or merge its behaviour into a child (asChild).

Compound components

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const TabsContext = createContext(null);

function Tabs({ value, defaultValue, onValueChange, children }) {
const [inner, setInner] = useState(defaultValue);
const isControlled = value !== undefined;
const active = isControlled ? value : inner;
const select = v => { if (!isControlled) setInner(v); onValueChange?.(v); };
return <TabsContext value={{ active, select }}>{children}</TabsContext>;
}

function Tab({ value, children }) {
const { active, select } = use(TabsContext);
return (
<button role="tab" aria-selected={active === value} onClick={() => select(value)}>
{children}
</button>

);
}

function Panel({ value, children }) {
const { active } = use(TabsContext);
return active === value ? <div role="tabpanel">{children}</div> : null;
}

Tabs.Tab = Tab;
Tabs.Panel = Panel;

Consumers compose freely — reorder tabs, put icons inside, wrap panels — without a prop explosion like tabs={[{ label, content, icon, disabled }]}. The same code supports controlled and uncontrolled use, which is the convention to follow for every stateful library component. (A production version also needs aria-controls, ids from useId, and arrow-key navigation — exactly the work a headless library does for you.)

Headless first

Splitting behaviour from appearance lets one design system serve many products. The behaviour layer — keyboard handling, focus management, ARIA attributes, positioning — is the hard, bug-prone part; the visual layer is the part each product wants to change. Mature headless libraries implement the ARIA patterns and test them across browsers and screen readers. Wrapping one with your design tokens is usually cheaper and more accessible than building from divs.

Polymorphism: as vs asChild

1
2
3
4
5
6
7
8
9
10
11
12
13
// `as` prop: the component chooses the element
type ButtonProps<C extends React.ElementType> = {
as?: C;
variant?: "primary" | "ghost";
} & Omit<React.ComponentPropsWithoutRef<C>, "as" | "variant">;

function Button<C extends React.ElementType = "button">({ as, variant = "primary", ...rest }: ButtonProps<C>) {
const Comp = as ?? "button";
return <Comp className={`btn btn-${variant}`} {...rest} />;
}

<Button as="a" href="/docs">Docs</Button>;
1
2
3
4
5
// `asChild`: merge props into the single child (Radix Slot pattern)
<Button asChild>
<Link href="/docs">Docs</Link>
</Button>

The as prop gets complicated in TypeScript (generic props, refs, conflicting prop names) and type-checks slowly in big unions. asChild keeps the child's own types and works with any router's Link, at the cost of a merging step (class names, event handlers, refs). Many design systems now prefer asChild or a render prop.

Trick: keep library components free of data fetching and app state. A component library that imports your API client cannot be reused, tested in isolation, or shown in Storybook.


Practice Exercises

Exercise 1 — Classify the profile

A table re-renders all 300 rows when a user types in a header search box. Each row takes 0.4 ms. What do you do first?

Solution

Classify: many components render with the same output, total about 120 ms per keystroke. First try structure: keep the search box state in the header component (move state down) so the table does not render on keystrokes; filter with useDeferredValue so typing stays urgent. Memoise rows (memo, or the compiler) only if the table must still re-render. Profile again after each change.

Exercise 2 — Why is memo useless here?

1
2
<MemoRow item={item} onSelect={() => select(item.id)} style={{ padding: 8 }} />
Solution

onSelect and style are new on every render, so the prop comparison always fails. Hoist style to a module constant; pass onSelect={select} (stable via useCallback or useReducer dispatch) and let the row call onSelect(item.id). Or enable React Compiler, which memoises both.

Exercise 3 — Boundary placement

A dashboard has a navigation bar and six independent widgets. One widget's third-party chart throws on bad data and the whole page goes white. Design the boundaries.

Solution

A root boundary with a reload page; a route-level boundary under the navigation bar so navigation survives; one boundary per widget with a small "This widget failed — retry" fallback and resetKeys tied to the widget's inputs. Report from componentDidCatch (or onError) and from the root's onCaughtError / onUncaughtError.

Exercise 4 — Rewrite a brittle test

1
2
3
4
5
const { container } = render(<Login />);
fireEvent.change(container.querySelector("#email"), { target: { value: "a@b.co" } });
fireEvent.click(container.querySelector(".btn-primary"));
expect(mockLogin).toHaveBeenCalled();
Solution
1
2
3
4
5
6
const user = userEvent.setup();
render(<Login />);
await user.type(screen.getByLabelText(/email/i), "a@b.co");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText(/welcome back/i)).toBeInTheDocument();

It queries by what the user sees (and proves the label and button name exist), uses real event sequences, mocks the network with MSW instead of the module, and asserts the visible result.

Exercise 5 — Make a <Disclosure> compound component

Design <Disclosure>, <Disclosure.Button> and <Disclosure.Panel> that work controlled and uncontrolled and are accessible.

Solution

The root holds open (controlled via open / onOpenChange, uncontrolled via defaultOpen) and a useId() id in context. Disclosure.Button renders a <button aria-expanded={open} aria-controls={id}> that toggles. Disclosure.Panel renders <div id={id} hidden={!open}>. Test it with role queries: getByRole("button", { name }), then assert aria-expanded and panel visibility after user.click.


Cheat-sheet

Task Tool / rule
Find why a component rendered DevTools Profiler → "Record why each component rendered"
Measure in code <Profiler id onRender>; production needs the profiling build
React work on the browser timeline React 19.2 Performance Tracks (Scheduler, Components)
Fewer renders, no runtime cost Move state down; pass children; split context
Memoise by hand memo + stable props (useMemo, useCallback, module constants)
Memoise automatically React Compiler; keep explicit memo where identity is correctness
Long lists Virtualise (@tanstack/react-virtual); content-visibility for long content
Split code lazy(() => import(...)) at module level + <Suspense>; preload on hover
Catch render errors Error boundary (class or react-error-boundary), per route and per widget
Report errors createRoot(..., { onCaughtError, onUncaughtError, onRecoverableError })
Component tests Vitest + RTL + user-event + MSW; query by role
E2E Playwright, role locators, auto-waiting, small suite
a11y automation eslint-plugin-jsx-a11y, @axe-core/playwright
Reusable components Compound + controlled/uncontrolled + headless behaviour + asChild

What's next

This post completes the core of the React Deep Dive. The Next.js Deep Dive series continues from here with the framework layer: the App Router, Server Components and Server Functions in production, streaming, caching and deployment. The Vue Deep Dive series shows the other major model — fine-grained reactivity with proxies — and is a useful contrast to everything in Posts 00 and 01. The JavaScript Deep Dive series covers the language and runtime underneath all of them. The table below lists the React topics planned as later posts in this series.

Planned future extensions

# Topic Planned content
04 View Transitions and animation <ViewTransition>, addTransitionType, Fragment refs (experimental as of 19.2), Motion, reduced motion
05 Forms at scale React Hook Form vs Actions, schema validation with Zod, multi-step forms, server errors
06 TypeScript for React Generic components, discriminated-union props, polymorphic types, typing context and reducers
07 SSR and hydration without a framework renderToPipeableStream, prerender / resume, hydration mismatches, streaming order
08 Micro-frontends and monorepos Module Federation, shared React versions, design-system packaging, Turborepo / Nx
09 Real-time and offline WebSockets with useSyncExternalStore, optimistic sync, TanStack Query persistence, service workers
10 React Native and cross-platform New Architecture, Expo, sharing logic across web and native
11 Large-scale migrations Class to hooks, React 18 to 19 codemods, enabling React Compiler across a monorepo

References