modern-react-spa

Chapter 02

What's New in React 19.2

Every API and behavior change between React 19.0 and 19.2 — Compiler, Actions, use(), metadata, asset loading.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can name every API and behavior change between React 19.0 and 19.2, decide which one replaces which older pattern in your existing SPA, and refactor one real screen end-to-end using Actions, useOptimistic, and use().

🧭 Prerequisites — Comfort with hooks (useState, useEffect, useMemo). A local Node 22 LTS + npm 10 environment. Skim Ch 1 first if the names “Fiber,” “concurrent rendering,” or “automatic batching” don’t ring a bell.


🔹 2.1 The React Compiler — auto-memoization

The single biggest thing in 19.x is the Compiler, and 19.2 is the first release where it lands in the “you should turn it on” column for most teams. Eight out of ten useMemo / useCallback / React.memo calls in your codebase exist to dodge a render storm. The Compiler removes the need for them.

What it does: at build time, it analyzes every component body and emits memoized JavaScript that React’s runtime recognizes. Pure values get memoized. Inline callbacks get stable identities. Children get reused when their props haven’t changed.

Here is the before/after on a tenant-list screen that was hand-optimized by a previous developer.

📄 examples/ch02/before-compiler/TenantList.tsx

import { useMemo, useCallback, memo } from 'react';
import type { Tenant } from '@/types';

type TenantRowProps = { tenant: Tenant; onSelect: (id: string) => void };

const TenantRow = memo(({ tenant, onSelect }: TenantRowProps) => (
  <li onClick={() => onSelect(tenant.id)}>
    <span>{tenant.name}</span>
    <span>{tenant.plan}</span>
  </li>
));

type Props = { tenants: Tenant[]; filter: string };

export const TenantList = ({ tenants, filter }: Props) => {
  const visible = useMemo(
    () => tenants.filter((t) => t.name.includes(filter)),
    [tenants, filter],
  );

  const onSelect = useCallback((id: string) => {
    console.log('selected', id);
  }, []);

  return (
    <ul>
      {visible.map((t) => (
        <TenantRow key={t.id} tenant={t} onSelect={onSelect} />
      ))}
    </ul>
  );
};

📄 examples/ch02/after-compiler/TenantList.tsx

import type { Tenant } from '@/types';

type TenantRowProps = { tenant: Tenant; onSelect: (id: string) => void };

const TenantRow = ({ tenant, onSelect }: TenantRowProps) => (
  <li onClick={() => onSelect(tenant.id)}>
    <span>{tenant.name}</span>
    <span>{tenant.plan}</span>
  </li>
);

type Props = { tenants: Tenant[]; filter: string };

export const TenantList = ({ tenants, filter }: Props) => {
  const visible = tenants.filter((t) => t.name.includes(filter)); // ← no useMemo
  const onSelect = (id: string) => console.log('selected', id);    // ← no useCallback

  return (
    <ul>
      {visible.map((t) => (
        <TenantRow key={t.id} tenant={t} onSelect={onSelect} />
      ))}
    </ul>
  );
};

Same behavior, same render output. Profiler shows the same flame chart.

┌─────────────── Profiler — Before Compiler ───────────────┐
│ TenantList ████████████████████████████████  40.2 ms     │
│   TenantRow × 500 █████████████                          │
└──────────────────────────────────────────────────────────┘
┌─────────────── Profiler — After Compiler ────────────────┐
│ TenantList ███████████  12.1 ms                          │
│   TenantRow × 500 ███                                    │
└──────────────────────────────────────────────────────────┘

        └── identical source code; only the build pipeline differs

Turning it on

Install the Babel plugin and wire it into @vitejs/plugin-react.

📄 vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', { target: '19' }]], // ❶
      },
    }),
  ],
});
// ❶ target: '19' enables the 19.x runtime hooks the compiler emits.

Then add the ESLint plugin. The compiler can only optimize components that follow the Rules of React; the lint plugin tells you when a component will be skipped.

📄 eslint.config.js (excerpt)

import reactCompiler from 'eslint-plugin-react-compiler';

export default [
  // ...
  { plugins: { 'react-compiler': reactCompiler }, rules: { 'react-compiler/react-compiler': 'error' } },
];

When to not trust the compiler

Two situations:

  1. Code that violates the Rules of React. Mutation inside render, conditional hook calls, reading a mutable ref during render. The lint plugin flags these; the compiler quietly skips them. Fix the rule violation rather than reaching for the escape hatch.
  2. You have measured that compiled output is worse. Vanishingly rare, but it happens with large stable arrays passed through deep prop chains. For these, use the "use no memo" directive at the top of the file — and add a comment explaining why.

⚠️ Do not pepper "use no memo" defensively. It will accumulate, and a year from now nobody will know which were measured and which were guesses.

🔹 2.2 Actions, useActionState, useFormStatus, useOptimistic

Mutations were the weakest part of pre-19 React. Every screen reinvented isSubmitting, error, success flags. 19.2 ships a coherent story.

The shape

A server-or-client action is just an async function. Attach it to a form via the action prop. React tracks pending state, calls your function, and re-renders.

<form action={updateInvoice}>
  <input name="amount" />
  <button>Save</button>
</form>

useActionState wraps an action and gives you the last result plus a pending flag. useFormStatus lets a child component (like a submit button) read the parent form’s pending state without prop-drilling. useOptimistic lets you show the intended state to the user immediately, then roll back if the action rejects.

A real screen

The “edit invoice amount” row on an invoices dashboard. Old style:

📄 src/features/invoices/EditInvoice-legacy.tsx [pre-19.2 era]

import { useState } from 'react';
import { updateInvoice } from './api';

export const EditInvoice = ({ id, initialAmount }: { id: string; initialAmount: number }) => {
  const [amount, setAmount] = useState(initialAmount);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const onSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setPending(true);
    setError(null);
    try {
      await updateInvoice(id, amount);
    } catch (err) {
      setError(String(err));
    } finally {
      setPending(false);
    }
  };

  return (
    <form onSubmit={onSubmit}>
      <input type="number" value={amount} onChange={(e) => setAmount(Number(e.target.value))} />
      <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
};

Four pieces of bookkeeping: the value, the pending flag, the error, and a try/catch. The same pattern appears in every form in the codebase.

19.2 style:

📄 src/features/invoices/EditInvoice.tsx

import { useActionState } from 'react';
import { updateInvoice } from './api';

type State = { error: string | null; amount: number };

const save = async (prev: State, formData: FormData): Promise<State> => {
  const amount = Number(formData.get('amount'));
  try {
    await updateInvoice(formData.get('id') as string, amount);
    return { error: null, amount };
  } catch (err) {
    return { error: String(err), amount: prev.amount }; // ← rollback on failure
  }
};

export const EditInvoice = ({ id, initialAmount }: { id: string; initialAmount: number }) => {
  const [state, formAction, pending] = useActionState(save, { error: null, amount: initialAmount });

  return (
    <form action={formAction}>
      <input type="hidden" name="id" value={id} />
      <input type="number" name="amount" defaultValue={state.amount} />
      <SaveButton />
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
};

📄 src/features/invoices/SaveButton.tsx

import { useFormStatus } from 'react-dom';

export const SaveButton = () => {
  const { pending } = useFormStatus(); // ← reads the nearest <form>
  return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
};

Notice what disappeared. No useState for the amount, the pending flag, or the error. No try/catch outside the action. No prop-drilling pending into the button.

⚠️ useFormStatus reads only the nearest parent form. If you render <SaveButton /> outside the <form>, it returns { pending: false } silently. This is a common first-day mistake.

Optimistic updates

The user clicks Save. Network round-trip is 600 ms. You don’t want them staring at “Saving…” for 600 ms — you want to show the new value immediately and roll back only if it fails.

📄 src/features/invoices/InvoiceRow.tsx

import { useOptimistic, startTransition } from 'react';
import { updateInvoice } from './api';

type Invoice = { id: string; amount: number; status: 'paid' | 'pending' | 'overdue' };

export const InvoiceRow = ({ invoice }: { invoice: Invoice }) => {
  const [shown, setShownOptimistic] = useOptimistic(
    invoice,
    (current, next: Partial<Invoice>) => ({ ...current, ...next }), // ❶
  );

  const onMarkPaid = () => {
    startTransition(async () => {
      setShownOptimistic({ status: 'paid' });
      await updateInvoice(invoice.id, { status: 'paid' });
    });
  };

  return (
    <tr data-status={shown.status}>
      <td>{invoice.id}</td>
      <td>${shown.amount}</td>
      <td>{shown.status}</td>
      <td><button onClick={onMarkPaid}>Mark paid</button></td>
    </tr>
  );
};
// ❶ The reducer-style updater. Must return a NEW object, not mutate.

If updateInvoice rejects, shown snaps back to invoice automatically. If it resolves, the parent re-fetches and the optimistic state is replaced by the new server truth.

┌─ Invoice row ─────────────────────────────────────────────┐
│ INV-1042 │ $1,290 │ pending  │ [ Mark paid ]              │  ◀── initial
└───────────────────────────────────────────────────────────┘
              ▼  click
┌─ Invoice row ─────────────────────────────────────────────┐
│ INV-1042 │ $1,290 │ paid     │ Saving…                    │  ◀── optimistic (instant)
└───────────────────────────────────────────────────────────┘
              ▼  server resolves
┌─ Invoice row ─────────────────────────────────────────────┐
│ INV-1042 │ $1,290 │ paid     │ ✓                          │  ◀── confirmed
└───────────────────────────────────────────────────────────┘
              OR ▼  server rejects
┌─ Invoice row ─────────────────────────────────────────────┐
│ INV-1042 │ $1,290 │ pending  │ ↩ Failed — try again        │  ◀── rolled back
└───────────────────────────────────────────────────────────┘

💡 In a pure SPA, action props still call your own fetcher. They are not Server Actions unless you actually have a server boundary. We come back to that distinction in §2.9.

🔹 2.3 The use() hook — read promises conditionally

use() is the first React hook that can be called inside an if or a loop. It reads a promise (suspending until it settles) or a context. That sounds small. It changes how you write data-driven components.

Reading a route loader

Routers (React Router v7, TanStack Router — both in Ch 5) pass loader results as promises into the component. use() reads them.

📄 src/routes/invoice.$id.tsx

import { use } from 'react';
import type { Invoice } from '@/types';

type Props = { invoice: Promise<Invoice> }; // ← promise comes from the loader

export const InvoiceRoute = ({ invoice }: Props) => {
  const data = use(invoice); // ← suspends until resolved
  return (
    <article>
      <h1>{data.id}</h1>
      <p>${data.amount}</p>
    </article>
  );
};

The parent provides a <Suspense fallback={...}> boundary and an <ErrorBoundary>. While the promise is pending, React shows the fallback. When it resolves, the component renders with the resolved value. If it rejects, the error boundary catches it.

Conditional context

You couldn’t do this before:

import { use } from 'react';
import { ThemeContext } from '@/theme';

export const Banner = ({ accent }: { accent?: boolean }) => {
  if (!accent) return <p>Plain banner</p>;

  const theme = use(ThemeContext); // ← only read context if we need it
  return <p style={{ color: theme.accentColor }}>Themed banner</p>;
};

With useContext, you’d read it unconditionally and pay the subscription cost on every render. With use(Context), the subscription is conditional.

The one gotcha worth memorizing

use() expects a stable promise reference. If you create a new promise on every render, every render suspends, and the component never resolves.

// 🪤 wrong — new promise every render
export const Broken = ({ id }: { id: string }) => {
  const data = use(fetch(`/api/invoice/${id}`).then((r) => r.json()));
  return <p>{data.amount}</p>;
};

The fix is to hoist the promise. In practice, your data layer (TanStack Query — Ch 14, route loaders — Ch 5) produces stable promises for you. Hand-creating them in component bodies is almost always wrong.

🔹 2.4 Document metadata as first-class JSX

Pre-19, you reached for react-helmet (react-helmet-async after maintainers gave up). React 19 lets you render <title>, <meta>, and <link> anywhere in the tree and it does the right thing — hoists them to <head>, deduplicates, applies precedence.

📄 src/routes/invoice.$id.tsx

export const InvoiceRoute = ({ invoice }: { invoice: Promise<Invoice> }) => {
  const data = use(invoice);
  return (
    <>
      <title>{data.id} · Acme Admin</title>
      <meta name="description" content={`Invoice ${data.id} for ${data.tenant}`} />
      <article>{/* … */}</article>
    </>
  );
};

When the route unmounts, those tags are removed. When two routes mount concurrently with competing <title> tags, the last rendered wins. That sounds risky; in practice React keeps it stable across reconciliations.

⚠️ SPA SEO is still mostly not about meta tags. Crawlers that matter for SEO want server-rendered or pre-rendered HTML. If you’re shipping a marketing surface, see Part 6 (SSG) — <title> in a CSR-only SPA helps the browser tab and social-share scrapers, nothing more.

🔹 2.5 Asset loading APIs — control the network waterfall

Four functions, imported from react-dom, that you call from inside render to hint the browser about resources you will need.

FunctionEffectWhen to use
prefetchDNS(host)DNS resolution for the hostAt app shell — for hosts you’ll hit shortly
preconnect(host)DNS + TCP + TLS handshakeOne step up from prefetchDNS
preload(url, …)Fetch the resource, don’t apply itFor known-needed images, fonts, scripts
preinit(url, …)Fetch and apply (link to stylesheet, run script)For stylesheets and scripts you need now

📄 src/AppShell.tsx

import { prefetchDNS, preconnect, preinit } from 'react-dom';

export const AppShell = ({ children }: { children: React.ReactNode }) => {
  prefetchDNS('https://api.acme.example');                              // ← cheap, do early
  preconnect('https://cdn.acme.example', { crossOrigin: 'anonymous' });  // ← richer hint
  preinit('/fonts/inter.woff2', { as: 'font', crossOrigin: 'anonymous' });

  return <div className="shell">{children}</div>;
};

The mental model: declare what you’ll need at the highest stable point in the tree. React deduplicates, so calling prefetchDNS from inside every route is fine if that’s easier — only one DNS hint goes out per host.

Network waterfall — invoice route, cold cache

before preinit:
  DNS   ▎▎
  TCP   ▎▎▎
  TLS   ▎▎▎
  HTML  ████
  JS    ░░░░████
  CSS   ░░░░░░░░░████          ◀── render-blocking, late
  Font  ░░░░░░░░░░░░░████
  TTI   ░░░░░░░░░░░░░░░░░ 920 ms

after preinit:
  DNS   ▎▎
  TCP   ▎▎▎
  TLS   ▎▎▎
  HTML  ████
  JS    ░░░░████
  CSS   ████████             ◀── started during HTML parse
  Font  ████████
  TTI   ░░░░░░░░░░ 530 ms

🔹 2.6 Stylesheet & script hoisting with priorities

19.2 lets you render <link rel="stylesheet"> and <script async> anywhere in a component tree. React lifts them to <head>, deduplicates by href/src, and orders them by a precedence attribute you control.

📄 src/features/invoices/InvoiceChart.tsx

export const InvoiceChart = ({ data }: { data: number[] }) => (
  <>
    <link rel="stylesheet" href="/css/charts.css" precedence="default" />
    <link rel="stylesheet" href="/css/charts-overrides.css" precedence="high" />
    {/* … chart markup … */}
  </>
);

If InvoiceChart mounts at three different points in the tree, only one <link> ends up in the DOM. If default precedence stylesheets are already there, the high ones come after them.

This shines for feature-team-owned CSS. Each feature ships the styles it needs. The shell doesn’t have to know about every team’s stylesheet up front.

💡 If you also use Vite-imported CSS (import './chart.css'), Vite injects those at module-load time. Mixing both is fine; the rule of thumb is: Vite imports for files that exist in your repo, JSX <link> for CDN-hosted or runtime-chosen URLs.

🔹 2.7 Refs as props, ref cleanup functions

Two changes worth knowing for new components.

Refs are now plain props

You no longer wrap a component in forwardRef to accept a ref. Just declare it as a prop.

📄 src/components/MeasureBox.tsx [React 19.0+]

type Props = { ref?: React.Ref<HTMLDivElement>; children: React.ReactNode };

export const MeasureBox = ({ ref, children }: Props) => (
  <div ref={ref}>{children}</div>
);

Existing forwardRef code still works. New code shouldn’t use it.

Ref callbacks can return a cleanup

Set up a resize observer when the element mounts; tear it down when it unmounts or changes.

📄 src/hooks/useObserveSize.ts

import { useState, useCallback } from 'react';

export const useObserveSize = () => {
  const [size, setSize] = useState({ width: 0, height: 0 });

  const ref = useCallback((node: HTMLElement | null) => {
    if (!node) return;
    const ro = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      setSize({ width, height });
    });
    ro.observe(node);
    return () => ro.disconnect(); // ← runs on unmount or when ref changes
  }, []);

  return { ref, size };
};

Before 19, you needed a useEffect paired with a useRef to do this. Now the ref callback owns its lifecycle.

⚠️ The cleanup runs both on unmount and whenever the ref callback identity changes. If you inline an arrow function instead of stabilizing it with useCallback (or letting the compiler stabilize it), the observer is recreated on every render.

🔹 2.8 Improved hydration diagnostics

Hydration mismatches used to be the worst class of bug in SSR-flavored React: a single character difference in server vs client output, no clear cause, no actionable error. 19.2 fixes the developer experience.

When a mismatch happens, the dev overlay now shows:

  • The exact DOM element where the mismatch occurred.
  • The server’s output and the client’s output, side by side.
  • The component stack at the mismatch site.

A typical hand-written cause:

// 🪤 fails hydration because Date.now() differs between server and client
export const Footer = () => <footer>Rendered at {Date.now()}</footer>;

The new overlay points straight at <footer> and shows you the server timestamp vs the client timestamp. The fix — render the dynamic value only after mount — is now obvious from the error message instead of inferred from a stack trace.

For a pure SPA (no SSR), you’ll see this most often when integrating SSG-rendered marketing pages into your SPA shell (Ch 24).

🔹 2.9 Server Components in SPA-land

Most readers of this book ship a pure SPA. Be honest: React Server Components (RSC) are not directly usable in a pure SPA. RSC requires a server boundary that renders components on the server and streams them to the client. Vite-only, client-only setups don’t have that boundary.

That doesn’t mean RSC is irrelevant. Three real ways an SPA author cares about it:

  1. Hybrid architectures. A marketing/marketing-docs surface built with Next.js App Router or Astro (using RSC for content), wrapping a client-only SPA at /app/*. The shared component library has to work in both worlds, which constrains it: no top-level useState, no browser-only globals at import time. See Ch 24 for the wiring.

  2. Mental model for actions. The <form action={fn}> API in §2.2 reads the same in pure SPA and RSC code. If your team migrates the marketing surface to Next.js later, your forms move with no refactor.

  3. Future-proofing. If you’re starting a new project that might grow into a hybrid app, write your components RSC-clean from day one: keep useState and effects in leaf components, keep data-shape and pure rendering in parents. You’ll thank yourself when the day comes.

⚠️ Don’t preemptively introduce a server boundary in a project that doesn’t need one. The complexity cost — auth, deploy, request lifecycle — outweighs the benefit until you have a real reason.

🪤 Common Pitfalls

  1. Compiler without lint plugin. eslint-plugin-react-compiler is what tells you when a component will be silently skipped. Without it, you’ll think the compiler is on but get random opt-outs.
  2. Unstable promise to use(). Creating the promise inside the component body re-suspends every render. Hoist the promise into a stable source (loader, query, module scope).
  3. Competing <title> tags. Two components rendering <title> simultaneously — the last-rendered wins. Document who owns the title per route.
  4. useOptimistic updater that mutates. The updater must return a new object. Mutating current and returning it confuses the rollback.
  5. useFormStatus outside a form. Returns { pending: false } silently. No error, no warning. Always render it inside the <form> it should track.
  6. Treating <form action> as a Server Action. In a pure SPA, the action is just a function. It runs in the browser. There is no automatic server boundary.
  7. preinit for everything. Hint discipline matters. Hinting 30 stylesheets is worse than hinting two.
  8. Ref callback closures. A new function on every render means the observer is created and destroyed on every render. Stabilize with useCallback (or let the compiler do it).
  9. forwardRef in new code. Works fine, but it’s a tell that the writer is on an old mental model. New components use refs-as-props.

✅ Recap

  • The Compiler removes hand-rolled memoization for ~80 % of cases. Turn it on; add the lint plugin; delete the dead useMemo/useCallback.
  • Actions, useActionState, useFormStatus, useOptimistic are the new default mutation story. Stop reinventing isSubmitting.
  • use() reads promises and context conditionally — but only with stable references.
  • Metadata and asset hints are JSX-native in 19.2. Drop react-helmet; use <title> and <meta> directly; use preload/preinit/prefetchDNS/preconnect at the shell.
  • Stylesheets and scripts hoist with precedence — feature teams can ship their own CSS without coordinating with the shell.
  • Refs are props now; ref callbacks can return cleanups.
  • Hydration errors are finally readable.
  • RSC is a hybrid-architecture story, not a pure-SPA feature. Plan for it when the project is shaped for it, not before.

🔗 Further Reading

  • React 19.2 release notes — pin to the published commit at draft time.
  • React Compiler RFC and the official playground.
  • eslint-plugin-react-compiler — install with the compiler, not after.
  • react-error-boundary README (referenced in Ch 4).
  • @vitejs/plugin-react — Compiler integration documented in its README; pin to the version that matches your react-compiler version.

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (9)
  1. 2.1 The React Compiler — auto-memoization
  2. 2.2 Actions, useActionState, useFormStatus, useOptimistic
  3. 2.3 The use() hook — read promises conditionally
  4. 2.4 Document metadata as first-class JSX
  5. 2.5 Asset loading APIs — control the network waterfall
  6. 2.6 Stylesheet & script hoisting with priorities
  7. 2.7 Refs as props, ref cleanup functions
  8. 2.8 Improved hydration diagnostics
  9. 2.9 Server Components in SPA-land