Contents
28 Chapter

React 19 features in one place

Actions · useActionState · useFormStatus · useOptimistic · use() · React Compiler · ref as prop. The React 19 features scattered across Chapters 22~27 collected in one catalog.

Chapter 27 closed out the main content of Part 4. Across Part 4 we drew the big picture of Server Components and App Router, then layered data fetching, Suspense, and Server Actions on top. Along the way, features newly stabilized in React 19 showed up here and there. This chapter collects them in one catalog.

This chapter targets two audiences at the same time.

  • Readers who have followed Part 4 step by step: a recap chapter that ties the scattered pieces into one picture.
  • Readers familiar with React up through 18 but not 19: an entry point for catching up on the 18 → 19 changes in one sitting when you do not have time to read the book from the start.

For the latter, this chapter reads on its own with the flow intact. Each section walks “what problem did it try to solve” → “the API shape” → “when you can skip it”.

The Part 4 trail — where each React 19 feature appeared #

ChapterReact 19 features that appeared
Chapter 25 Data fetching and caching(indirect) The async function model of Server Components
Chapter 26 Suspense and use()use() hook, passing a Promise as a prop
Chapter 27 Server Actions and formsActions API, useActionState, useFormStatus, useOptimistic
Across the boardref as prop, React Compiler, Document Metadata

Let’s walk through that catalog one section at a time.

1. Actions API — the new standard for forms and mutations #

What problem it tries to solve #

Through React 18, the standard pattern for form mutations was the one we built in Chapter 9.

form mutations in the React 18 era
'use client';

import { useState, type FormEvent } from 'react';

function Form() {
  const [text, setText] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setSubmitting(true);
    try {
      const res = await fetch('/api/items', { method: 'POST', body: JSON.stringify({ text }) });
      if (!res.ok) throw new Error('failed');
      setText('');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'error');
    } finally {
      setSubmitting(false);
    }
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

The same boilerplate repeated every time. The submitting state, the error state, the result state, and the try/catch/finally that ties them together.

React 19’s Actions API standardizes this pattern. The model: “wire an async function directly into the form, and React tracks pending and the result on its own”.

The API shape #

An Action is an async function that takes arguments and returns a Promise.

the Action function signature
async function action(prevState: State, payload: FormData): Promise<State> {
  // validate, mutate, return result
}

Two key points.

  1. The first argument is the previous state. useActionState fills this slot with the previous call’s return value automatically.
  2. The return value becomes the next call’s prevState. The function manages state itself.

With Next.js’s 'use server' directive attached, it becomes a Server Action, and calling it from the client automatically runs it on the server (the RPC mechanism from Chapter 27).

<form action={fn}> — meeting browser native #

React 19 extends <form>’s action prop to accept a function.

wiring a function directly into the action prop
<form action={myAction}>
  <input name="text" />
  <button>Submit</button>
</form>

When the browser’s standard form submission fires, React intercepts it and calls myAction(formData). Even with JavaScript disabled, the form still works as a plain POST request (for a Server Action). This model is the basis for the progressive enhancement we saw in Chapter 27.

When you can skip it #

For a simple mutation that is not a form (e.g., a like-button click), there is no need to force the Action API in. Just call fetch or a Server Action inside the onClick handler. Actions shine when form data + pending + result state all travel together.

2. useActionState — bundle form state into one hook #

What problem it tries to solve #

The Actions API on its own lets you submit a form, but showing the result message on screen needs separate state.

showing the result — the messy version
const [result, setResult] = useState<{ message: string } | null>(null);

async function action(formData: FormData) {
  const res = await myAction(formData);
  setResult(res);
}

useActionState bundles this pattern into one hook.

The API shape #

using useActionState
const [state, formAction, isPending] = useActionState(
  async (prevState, formData) => {
    // ... validate and mutate
    return { message: 'done', success: true };
  },
  { message: '', success: false },  // initial state
);

return (
  <form action={formAction}>
    {state.message && <p>{state.message}</p>}
    <input name="text" />
    <button disabled={isPending}>Submit</button>
  </form>
);

Three return values.

  • state: the Action’s last return value (or the initial state)
  • formAction: the wrapped function you can pass straight to <form action={...}>
  • isPending: a boolean for whether a submission is in flight

Showing validation errors on screen #

You can flow server-side validation results straight to the screen.

src/app/actions.ts
'use server';

type State = { error?: string; success?: boolean };

export async function createPost(prevState: State, formData: FormData): Promise<State> {
  const title = (formData.get('title') ?? '').toString().trim();
  if (!title) return { error: 'Please enter a title' };
  if (title.length > 100) return { error: 'Title must be under 100 characters' };

  await db.posts.insert({ title });
  return { success: true };
}

Validation happens on the server so it cannot be bypassed by the client, and the hook reflects the result on screen automatically.

When you can skip it #

If you do not need to show the result on screen (e.g., a simple GET form), or if useFormStatus alone is enough for a child-component-level indicator, you do not need useActionState.

3. useFormStatus — receive pending in a child component #

What problem it tries to solve #

useActionState’s isPending only works within the same component. But a reusable component like SubmitButton needs to know its parent form’s pending state, and previously you had to thread it through props every time.

useFormStatus automatically subscribes to the state of the nearest parent <form>.

The API shape #

src/app/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export default function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending, data, method, action } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : children}
    </button>
  );
}

The most commonly used return value is pending. The rest (data, method, action) are for special cases like debugging or progress indicators.

Caution — imported from react-dom #

useFormStatus comes from react-dom, not react. This is because the hook is bound to the DOM <form> element.

When you can skip it #

If there is one SubmitButton per form and it is defined in the same component, useActionState’s isPending is enough. A separate hook becomes necessary when a child component needs to know the parent form’s state.

4. useOptimistic — optimistic updates #

What problem it tries to solve #

While a mutation’s response is in flight, leaving the UI in its “previous state” hurts perceived speed. To get UX like Twitter’s like button turning red the instant you click, you have to update the screen before the response arrives.

Traditionally you had to manage that state by hand and write your own rollback logic on failure. useOptimistic standardizes the pattern.

The API shape #

using useOptimistic
'use client';

import { useOptimistic } from 'react';

type Item = { id: string; text: string };

function ItemList({ items }: { items: Item[] }) {
  const [optimisticItems, addOptimistic] = useOptimistic<Item[], Item>(
    items,
    (state, newItem) => [...state, newItem],
  );

  async function handleAdd(formData: FormData) {
    const text = (formData.get('text') ?? '').toString();
    const tempItem: Item = { id: 'temp-' + crypto.randomUUID(), text };
    addOptimistic(tempItem);
    await addItemAction(formData);
  }

  return (
    <>
      <ul>
        {optimisticItems.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
      <form action={handleAdd}>...</form>
    </>
  );
}

The useOptimistic(state, reducer) signature. The reducer is (current state, input) => new state. The reducer is invoked immediately on click to update the screen, and when the server response refreshes the real state, React reconciles the two tracks automatically.

Automatic rollback on failure #

The optimistically rendered UI is replaced by the real props once the mutation completes. If the mutation fails and props are not updated, it falls back to the previous state automatically. That is, you do not need to write rollback logic yourself.

When you can skip it #

  • Fast mutations (under 50ms): you do not need optimistic updates because the user will not notice.
  • Mutations where the result may differ from the input (e.g., the server assigns an ID or timestamp): a mismatch between the optimistic display and the actual one feels jarring.
  • Data where consistency matters (e.g., payments, balances): a hopeful-then-rolled-back UI is more confusing.

5. use() — a new hook for resolving Promises/Context #

What problem it tries to solve #

await inside a Server Component is powerful, but there are times you want to unwrap a Promise inside a Client Component too. The pattern “pass a Promise made on the Server to the Client” from Chapter 26 is one example.

The old hook rule was “don’t call a hook inside an if or for”. useContext followed the same rule. use() is a new hook that lifts both restrictions.

The API shape — Promise #

src/app/ItemList.tsx (Client)
'use client';

import { use } from 'react';

type Item = { id: string; text: string };

export default function ItemList({ itemsPromise }: { itemsPromise: Promise<Item[]> }) {
  const items = use(itemsPromise);
  return <ul>{items.map(i => <li key={i.id}>{i.text}</li>)}</ul>;
}

use(promise) shows the Suspense fallback until the Promise arrives, then returns the value. There must be a <Suspense> somewhere above the calling component.

The API shape — Context #

conditional Context usage
'use client';

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

function Card({ showTheme }: { showTheme: boolean }) {
  if (showTheme) {
    const theme = use(ThemeContext);
    return <div className={theme}>...</div>;
  }
  return <div>...</div>;
}

useContext could only be called at the top of the component, but use can be called inside a conditional.

When you can skip it #

  • In a Server Component, plain await is simpler. use(promise) is mainly meaningful when a Client Component receives a Promise as a prop.
  • If you always read a Context, useContext is fine. use’s value is when you need conditional calls.

6. React Compiler — automatic memoization #

What problem it tries to solve #

The memo / useMemo / useCallback we covered in Chapter 14 are powerful, but deciding where and when to use them is costly. Mis-specifying the dependency array also leads to stale closure bugs.

React Compiler (at the React 19 timeline, in RC) analyzes code at build time and applies memoization automatically. The burden of writing useMemo / useCallback by hand drops sharply.

Adoption #

install React Compiler
pnpm add -D babel-plugin-react-compiler

In Next.js 15, you enable an option in next.config.ts.

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

export default nextConfig;

It is recommended to also enable the ESLint plugin (the React Compiler rules in eslint-plugin-react-hooks); it warns about spots where the Compiler fails to analyze.

Boundaries where you still write by hand #

Automatic memoization is not a silver bullet. The following still need hand-written work.

  • Code that exposes referential identity externally — when passing a callback to a library that assumes the same reference, write useCallback yourself.
  • Code that needs explicit dependency tracking — the dependency array for useEffect stays by hand.
  • Cases where the Compiler gives up — dynamic indexing or highly dynamic code. The ESLint plugin warns you.

When you can skip it #

You can skip the Compiler on small projects or when performance is not the bottleneck. The basic principle of Chapter 14 (“re-renders cost”) still holds either way, so it is worth learning regardless of whether the Compiler is on.

7. ref as prop — the end of forwardRef #

What problem it tries to solve #

Through React 18, passing a ref into a child component required wrapping with forwardRef.

forwardRef through React 18
import { forwardRef } from 'react';

const FancyButton = forwardRef<HTMLButtonElement, { label: string }>(
  function FancyButton(props, ref) {
    return <button ref={ref}>{props.label}</button>;
  },
);

Types had to be written twice and the function signature differed from a normal function, adding cognitive overhead.

From React 19, ref behaves like any other prop.

The API shape #

React 19 — ref as prop
type Props = {
  label: string;
  ref?: React.Ref<HTMLButtonElement>;
};

function FancyButton({ label, ref }: Props) {
  return <button ref={ref}>{label}</button>;
}

forwardRef disappears, and ref is just another prop. Attaching a ref to the generic components from Chapter 20 (Context and generic components) also becomes much simpler.

Migration #

Existing forwardRef code keeps working for now (deprecation warning, scheduled for removal in a later major). New code uses ref as prop; old code migrates as time allows — that is the safe flow.

8. Other changes — small but worth knowing #

Document Metadata #

Wherever you render <title>, <meta>, and <link> inside the component tree, React hoists them into <head> automatically.

<title> inside a component
function PostPage({ post }: { post: Post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.summary} />
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </article>
  );
}

In Next.js you typically use export const metadata or generateMetadata, but this is useful when a library component carries its own metadata.

Hydration error messages #

In React 18, hydration mismatch messages were vague. From React 19, what mismatched on which node is reported concretely. Debugging time drops sharply.

Asset Loading — <link rel="preload" /> #

The API for preloading resources directly inside a component is cleaner now.

image preload
import { preload } from 'react-dom';

function HeroSection() {
  preload('/hero.jpg', { as: 'image' });
  return <img src="/hero.jpg" alt="hero" />;
}

It generates <link rel="preload"> automatically and dedups repeat calls. It will come back in Chapter 31 (performance and Web Vitals) as one of the tools for pulling LCP down.

18 → 19 changes — summary card #

A one-page card for readers jumping from 18 to 19 for the first time.

AreaReact 18React 19
Form mutationsuseState + fetch + try/catch<form action={fn}> + Actions
Form result statemanual useStateuseActionState
Pending indicatormanual useStateuseActionState’s isPending or useFormStatus
Optimistic updatesmanual state + rollbackuseOptimistic
Resolving Promisesonly Server Component await+ use(promise) (Client)
Conditional Contextnot allowed (hook rules)use(Context) allowed in conditionals
Memoizationmanual memo / useMemo / useCallback+ React Compiler (automatic)
Passing refforwardRefref as prop
Document headexternal library or metadata API<title> / <meta> inside components auto-hoisted
Asset preloadmanual <link>preload from react-dom

Try it — 18 → 19 mini migration #

Let’s move one small form from React 18 style to 19 style. Take the guestbook form from Chapter 27 of Part 4, write it backward into 18 style, then apply each section to convert it to 19.

  1. Starting point — React 18 style: rewrite MessageForm from Chapter 27’s guestbook with useState for text/submitting/error separately and <form onSubmit={...}> calling fetch directly. (Intentionally reproduce React 18 era code.)
  2. Step 1 — Adopt Actions: create a postMessage function with 'use server' and wire it into <form action={postMessage}>. Observe how the submitting state disappears.
  3. Step 2 — useActionState: replace the useState for result messages with useActionState. Throw validation errors on the server and confirm that the result flows automatically to the screen.
  4. Step 3 — useFormStatus: split the submit button into its own SubmitButton component and let it receive the parent form’s pending via useFormStatus. Once you write that button once, it is reusable across every form.

After these three steps, the difference between how React 18 era and React 19 era forms express the same task is etched into your hands.

Exercises #

  1. Write up the core idea. Summarize in 5 sentences how the Actions API’s “the function holds state” model differs from React 18’s “the component holds state” model. If you land on the point that useActionState’s second return value (formAction) is the mechanism by which React grants state to a function, you are close.
  2. When not to use useOptimistic. Among the three mutations below, which would be hurt by useOptimistic, and why? (a) Adding a Todo item, (b) Processing a payment, (c) Liking a comment. For each, describe how the user perceives failure.
  3. React Compiler impact assessment. Reread the useMemo / useCallback examples from Chapter 14 assuming React Compiler is on. Classify which code is automatically optimized as-is and which still needs you to express intent by hand. The common thread for code you keep by hand is “exposes referential identity externally”.

In one line: React 19 consolidates all the boilerplate of forms/mutations into one set — the Actions API (useActionState · useFormStatus · useOptimistic) — and adds the use() hook for handling Promises/Context more flexibly. React Compiler reduces the manual labor of Chapter 14 with automatic memoization, and forwardRef is simplified to ref as prop. Chapters 22~27 of Part 4 run on these tools, and Parts 5 and 6 stack on the same foundation.

Next chapter #

This chapter completely wraps up Part 4 (Modern Next.js). Part 5 (operations · testing · deployment) begins from Chapter 29 Component testing — Vitest + Testing Library. Part 5 is the bridge from “I can build React” to “I work with React”. The first chapter, Chapter 29, takes the components we built through Part 4 and adds a safety net — starting from the basics of Vitest and React Testing Library, through mocking and hook testing.

X