React 19

21. December, 2024 7 min read Develop

The one that took two years

React 19 went stable on 5 December 2024, and if you have been running the Canary channel or a recent Next.js, most of it will already be familiar. That is the interesting part of this release: almost nothing in it is new, it is just finally allowed to be in a version number.

When I wrote about React 18 I said it was a set of small changes laying foundations for later releases. This is the release those foundations were for. The difference is that the patterns arrived first, in frameworks, and the library caught up afterwards. Nearly everything below spent a year or more in Canary before it got a version number.

So this is less an announcement than an inventory. Here is what you can now rely on.

Actions

An Action is an async function passed straight to a form’s action prop. React calls it with the FormData, and handles the pending state, the errors and the reset for you.

// server-side action
'use server';

export async function createUser(formData) {
  const name = formData.get('name');
  // perform server-side operations
}
// client-side component
'use client';

import { createUser } from './actions';

export default function UserForm() {
  return (
    <form action={createUser}>
      <input name="name" />
      <button type="submit">Create User</button>
    </form>
  );
}

The function can be a plain client-side async function too. 'use server' only matters when you want it to run on the server, and it is a common misreading to think the directive is what makes something an Action.

useActionState

When you need the result of an Action rather than just firing it, useActionState wraps it and gives you back the state, a wrapped action to hand to the form, and a pending flag.

The catch is the signature. An action used with this hook takes the previous state as its first argument:

'use server';

export async function createUser(previousState, formData) {
  const name = formData.get('name');

  if (!name) {
    return { message: 'Name is required' };
  }

  // create the user
  return { message: `Created ${name}` };
}
'use client';
import { useActionState } from 'react';
import { createUser } from './actions';

export default function UserForm() {
  const [state, formAction, isPending] = useActionState(createUser, {
    message: '',
  });

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

If you have used this in Canary or in Next.js and remember it as useFormState from react-dom, that is the same hook. It moved to react and was renamed on the way.

useFormStatus

useFormStatus reads the state of the nearest parent form from inside a child component. This is the one I reach for most, because it means a submit button can know it is submitting without the form passing anything down to it.

'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      Submit
    </button>
  );
}

Note the import path. It comes from react-dom, not react, which will catch you at least once. It also has to be in a component rendered inside the form; calling it in the component that renders the <form> itself returns nothing useful.

useOptimistic

useOptimistic shows the result before the server confirms it, and rolls back automatically if the action fails.

'use client';
import { useOptimistic } from 'react';

function CommentList({ comments, addComment }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newComment) => [...state, newComment]
  );

  const handleSubmit = async formData => {
    const comment = formData.get('comment');

    addOptimisticComment(comment);
    await addComment(comment);
  };

  return (
    <form action={handleSubmit}>
      <input name="comment" />
      <button type="submit">Add Comment</button>
      <ul>
        {optimisticComments.map((c, i) => (
          <li key={i}>{c}</li>
        ))}
      </ul>
    </form>
  );
}

The first argument has to be the real state, not an empty array. Pass [] and you get an optimistic list that forgets everything the moment the action settles, which looks like a rendering bug and is not one.

The use API

use reads a resource during render. Given a promise it suspends until the promise resolves, and given a context it reads it.

import { use } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);

  return comments.map(comment => <p key={comment.id}>{comment.text}</p>);
}

It breaks the rules of hooks on purpose. You can call it inside a conditional or a loop, which is the entire reason it exists and reads wrong the first few times you see it. What you must not do is create the promise inside the component that consumes it, because a new promise on every render means suspending forever. Pass it in from a server component or a cache.

Static rendering APIs

react-dom/static gains prerender and prerenderToNodeStream. Unlike renderToString, these wait for all the data to load before producing output, which is what you want when you are generating static HTML rather than streaming it to a browser.

import { prerender } from 'react-dom/static';

async function handler(request) {
  const { prelude } = await prerender(<App />, {
    bootstrapScripts: ['/main.js'],
  });

  return new Response(prelude, {
    headers: { 'content-type': 'text/html' },
  });
}

Worth noticing that prerender resolves to an object, not an HTML string. prelude is a Web Stream. If you want a string you have to read the stream yourself, and if you are on Node without Web Streams, prerenderToNodeStream is the one you want.

Server Components, and what “stable” means here

React 19 includes the Server Components work that has been sitting in Canary for two years. Stable, in this case, means the feature will not break between minor versions.

It does not mean you can turn it on. Server Components need a bundler and a router that understand them, so in practice you get them through a framework. What React ships is the contract, not the implementation.

There is a second bit of small print worth knowing. The APIs a framework uses to implement Server Components do not follow semver in 19.x and may change between minors. If you maintain a framework or a bundler, pin your React version. If you are building an application on top of Next.js, this is somebody else’s problem.

The other thing to get straight: there is no directive for Server Components. Components are server components by default in an RSC setup, 'use client' opts out, and 'use server' marks a Server Action, which is a different thing entirely. Nearly everyone gets this backwards once.

// server action
'use server';

export async function createUser(formData) {
  // server-side logic
}
// client component
'use client';
import { createUser } from './actions';

export default function UserForm() {
  return (
    <form action={createUser}>
      <input name="name" />
      <button type="submit">Create User</button>
    </form>
  );
}

The smaller changes that will save you the most time

The headline features get the blog posts. These are the ones that will show up in your day-to-day work first:

  • ref is now a regular prop on function components. forwardRef is no longer needed, and a good chunk of wrapper boilerplate disappears.
  • <Context> renders as a provider directly. <Context.Provider> still works, but is on its way out.
  • <title>, <meta> and <link> rendered anywhere in a component get hoisted into the document head. No more helmet library for simple cases.
  • Errors from hydration mismatches finally tell you what did not match instead of printing two walls of HTML.

Upgrading

The codemods handle most of it and the official upgrade guide is worth the twenty minutes. Budget the time for everything that is not React, though: type packages, testing libraries, and any dependency with an opinion about forwardRef or Context.Provider.

I would not rush a large application over the line this month. The library is ready. The ecosystem around it is still catching up, and there is no prize for being early.

‘Till next time!