React 19 and a few habits worth revisiting

React 19.1 is out. Let’s use a small cat meme collection to look at a few React patterns.

We’ll start with some familiar advice about state and event handlers, then try the newer APIs. Most of those arrived in React 19.

Each example focuses on one idea. Here are the cats:

const collection = [
  {
    id: "oiia-cat",
    title: "OIIA OIIA (Spinning Cat)",
    tags: ["spinning", "music"],
  },
  {
    id: "chipi-cat",
    title: "Chipi Chipi Chapa Chapa",
    tags: ["dancing", "music"],
  },
];

Derived state

The parent passes in the collection and the selected tag. Here, we’re looking for spinning cats:

<CatCollection memes={collection} selectedTag="spinning" />

× Avoid: synchronizing a filtered copy

function CatCollection({ memes, selectedTag }) {
  const [visibleMemes, setVisibleMemes] = useState(memes);

  useEffect(() => {
    setVisibleMemes(
      selectedTag === "all"
        ? memes
        : memes.filter((meme) => meme.tags.includes(selectedTag)),
    );
  }, [memes, selectedTag]);

  return <p>{visibleMemes.length} matching memes</p>;
}

✓ Prefer: calculating during rendering

function CatCollection({ memes, selectedTag }) {
  const visibleMemes =
    selectedTag === "all"
      ? memes
      : memes.filter((meme) => meme.tags.includes(selectedTag));

  return <p>{visibleMemes.length} matching memes</p>;
}

We already have everything we need to work out which memes match. Keeping a separate copy in state means keeping it in sync too, with another render after the Effect runs. We can just filter the list while rendering and get the count from that.

Event handlers

Let’s add a Remove button to each meme. The parent keeps the collection in state and gives us onRemoveMeme, which removes a meme by its ID.

× Avoid: watching for a removal request

function MemeEntry({ meme, onRemoveMeme }) {
  const [pendingRemoval, setPendingRemoval] = useState(null);

  useEffect(() => {
    if (pendingRemoval === null) return;

    onRemoveMeme(pendingRemoval);
    setPendingRemoval(null);
  }, [pendingRemoval, onRemoveMeme]);

  return (
    <button type="button" onClick={() => setPendingRemoval(meme.id)}>
      Remove
    </button>
  );
}

✓ Prefer: handling the click directly

function MemeEntry({ meme, onRemoveMeme }) {
  return (
    <button type="button" onClick={() => onRemoveMeme(meme.id)}>
      Remove
    </button>
  );
}

We know what to remove as soon as the button is clicked. Saving that request in state just so an Effect can pick it up adds a detour. Once the parent removes the meme, our filtered list and count update on the next render.

Neither of these ideas is new in React 19. Effects are still useful for keeping a component in sync with something outside React. React’s guide to unnecessary Effects goes into this in more detail.

Actions and useActionState

Let’s save the collection. saveCollection(memes) sends it to our API and rejects if the request fails.

Manual approach

import { useState } from "react";

function SaveCollection({ memes }) {
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  async function handleSubmit(event) {
    event.preventDefault();
    setIsPending(true);
    setError(null);

    try {
      await saveCollection(memes);
    } catch {
      setError("Couldn't save the collection.");
    } finally {
      setIsPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <button disabled={isPending}>
        {isPending ? "Saving..." : "Save collection"}
      </button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

React 19: useActionState

import { useActionState } from "react";

function SaveCollection({ memes }) {
  const [error, submitAction, isPending] = useActionState(
    async (_previousError) => {
      try {
        await saveCollection(memes);
        return null;
      } catch {
        return "Couldn't save the collection.";
      }
    },
    null,
  );

  return (
    <form action={submitAction}>
      <button disabled={isPending}>
        {isPending ? "Saving..." : "Save collection"}
      </button>
      {!isPending && error && <p role="alert">{error}</p>}
    </form>
  );
}

useActionState gives us the last value our Action returned and tells us whether it’s still running. In this case, that value is an error message or null.

This Action runs in the browser and calls our API. There’s no Server Action involved here.

For forms like this, I’d use useActionState to keep the submission logic together. It saves a bit of bookkeeping. The request still takes just as long, and it won’t necessarily mean fewer renders.

Optimistic updates with useOptimistic

A favourite button is a good place to try an optimistic update. We can fill in the star as soon as someone clicks it, then undo the change if saving fails.

Both versions use the same saving logic. favoriteMeme calls our API and rejects if saving fails:

import { useActionState, useOptimistic } from "react";

function FavoriteButton({ memeId, initiallyFavorite = false }) {
  const [saved, submitAction, isPending] = useActionState(
    async (previous) => {
      try {
        await favoriteMeme(memeId);
        return { isFavorite: true, error: null };
      } catch {
        return {
          ...previous,
          error: "Couldn't save this favourite.",
        };
      }
    },
    { isFavorite: initiallyFavorite, error: null },
  );

  // One of the rendering options below goes here.
}

The Action’s result tells us what’s actually been saved. If the request fails, we keep the previous status and return an error message.

Wait for confirmation

return (
  <form action={submitAction}>
    <button
      disabled={isPending || saved.isFavorite}
      aria-pressed={saved.isFavorite}
    >
      {saved.isFavorite ? "★ Favourite" : "☆ Favourite"}
    </button>
    {isPending && <span role="status">Saving...</span>}
    {!isPending && saved.error && <p role="alert">{saved.error}</p>}
  </form>
);

React 19: optimistic feedback

const [isFavorite, setOptimisticFavorite] = useOptimistic(saved.isFavorite);

function favoriteAction() {
  setOptimisticFavorite(true);
  submitAction();
}

return (
  <form action={favoriteAction}>
    <button disabled={isPending || isFavorite} aria-pressed={isFavorite}>
      {isFavorite ? "★ Favourite" : "☆ Favourite"}
    </button>
    {isPending && <span role="status">Saving...</span>}
    {!isPending && saved.error && <p role="alert">{saved.error}</p>}
  </form>
);

With useOptimistic, the star fills in straight away. Once saving succeeds, the confirmed value catches up. If it fails, the star goes back to its previous state and we show the error.

The save takes just as long. The button just feels quicker to respond.

use() and Suspense

If our framework or data cache gives us a stable Promise, we can read its result during rendering with use().

The catch is that we need to reuse that Promise. Starting a fresh request every time the component renders causes trouble:

× Avoid: creating a Promise during rendering

import { use } from "react";

function AsyncCollection() {
  const memes = use(fetch("/api/memes").then((response) => response.json()));

  return <CatCollection memes={memes} selectedTag="all" />;
}

✓ Prefer: reading a stable Promise

import { use } from "react";

function AsyncCollection({ memesPromise }) {
  const memes = use(memesPromise);

  return <CatCollection memes={memes} selectedTag="all" />;
}

The first version starts over on every render, including when React retries after suspension. The second gets its Promise from a framework, cache, or Server Component that handles it for us.

Suspense gives us somewhere to put the loading message. An Error Boundary handles a rejected Promise. Here, I’m using react-error-boundary:

import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

function CollectionPage({ memesPromise }) {
  return (
    <ErrorBoundary fallback={<p>Couldn't load the cats.</p>}>
      <Suspense fallback={<p>Loading cats...</p>}>
        <AsyncCollection memesPromise={memesPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

While we’re waiting, React shows “Loading cats...”. Once the Promise resolves, React tries rendering again with the data. If it rejects, we get “Couldn't load the cats.” instead.

Suspense has been around for a while. use() is the new part here. Our data layer still needs to handle things like caching, retrying failed requests, and deciding when cached data needs refreshing.

ref as a prop

Let’s give the collection a search field, with a toolbar button that focuses it.

Before React 19: forwardRef

import { forwardRef } from "react";

const MemeSearch = forwardRef(function MemeSearch(props, ref) {
  return <input {...props} type="search" ref={ref} />;
});

React 19: ref as a prop

function MemeSearch({ ref, ...props }) {
  return <input {...props} type="search" ref={ref} />;
}

The parent uses either version in exactly the same way:

import { useRef } from "react";

function CollectionToolbar() {
  const searchRef = useRef(null);

  return (
    <div>
      <button type="button" onClick={() => searchRef.current?.focus()}>
        Search the collection
      </button>

      <MemeSearch
        ref={searchRef}
        aria-label="Search memes"
        placeholder="Find a cat"
      />
    </div>
  );
}

MemeSearch passes the ref along to its input. Once the input is attached to the DOM, searchRef.current points to it, so our button can call focus().

forwardRef still works. In React 19, a function component can just receive ref as a prop, which saves us the wrapper.

Context as a provider

Say a few nested components need to know whether we’re showing a grid or a list. Context lets them read that preference without threading it through every component in between.

We create the Context outside the components:

import { createContext } from "react";

const ViewContext = createContext("grid");

Before React 19: Context.Provider

function CollectionView({ view, children }) {
  return <ViewContext.Provider value={view}>{children}</ViewContext.Provider>;
}

React 19: Context as a provider

function CollectionView({ view, children }) {
  return <ViewContext value={view}>{children}</ViewContext>;
}

We can read it the same way as before:

import { useContext } from "react";

function MemeLayout({ children }) {
  const view = useContext(ViewContext);

  return <div className={`memes-${view}`}>{children}</div>;
}

The parent still chooses the view. Components reading the Context still update when that value changes. This is just a shorter way to write the provider.

Server Components

We can load the collection on the server too. In an app with Server Component support, that can look like this:

import { getMemes } from "./memes.server";
import { FavoriteButton } from "./FavoriteButton";

export default async function CollectionPage() {
  const memes = await getMemes();

  return (
    <ul>
      {memes.map((meme) => (
        <li key={meme.id}>
          <h2>{meme.title}</h2>
          <FavoriteButton memeId={meme.id} />
        </li>
      ))}
    </ul>
  );
}

getMemes could read straight from a database. The connection stays on the server.

CollectionPage runs there too, so its code and server-only dependencies don’t need to be shipped to the browser.

Our favourite button needs state and click handlers, so its module starts with "use client". It works like the button we wrote earlier.

We can load and prepare the collection on the server, then leave the interactive parts to Client Components.

This needs a framework that supports Server Components. Depending on the app, they can run at build time or for each request.

React Compiler

Back to our filtered collection. If filtering gets expensive, we might reach for useMemo to reuse the result when its inputs haven’t changed.

With React Compiler enabled, we can often let it handle that work:

Manual memoization

import { useMemo } from "react";

function CatCollection({ memes, selectedTag }) {
  const visibleMemes = useMemo(
    () =>
      selectedTag === "all"
        ? memes
        : memes.filter((meme) => meme.tags.includes(selectedTag)),
    [memes, selectedTag],
  );

  return <p>{visibleMemes.length} matching memes</p>;
}

With React Compiler enabled

function CatCollection({ memes, selectedTag }) {
  const visibleMemes =
    selectedTag === "all"
      ? memes
      : memes.filter((meme) => meme.tags.includes(selectedTag));

  return <p>{visibleMemes.length} matching memes</p>;
}

Two cats probably don’t need useMemo. For more demanding components, though, the compiler can save us a fair bit of manual memoization.

We still need to follow React’s rules. Duplicated state, accidental mutations, and unnecessary Effects are ours to sort out.

React Compiler reached release candidate on 21 April. It’s still opt-in, but worth trying if you’re tired of maintaining memoization by hand.

These are useful defaults for new React 19 code. Calculate values during rendering, keep user interactions in event handlers, and reach for Actions and Suspense when handling async work. React now handles more of the coordination that used to need separate state and Effects, so there’s less of it to write ourselves.