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.