React 19 added several features that make common jobs, such as forms and data loading, shorter and easier. You do not need them to build good apps, and older code that uses the earlier ways keeps working. But you will see these tools in new projects, so it is good to know what they do. This lesson explains each one with a small example.
The big idea: Actions
An action is a function that does something with data, usually asynchronous, such as sending a form to a server. React 19 lets you pass a function to a form's action prop. React runs it for you when the form is submitted, handles the “pending” period, and resets the form fields after a good submit.
function Newsletter() {
async function subscribe(formData) {
const email = formData.get('email');
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
}
return (
<form action={subscribe}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
Compare this with the forms in lessons 9 and 19: there is no onSubmit, no preventDefault, no FormData call, and no state for the fields. React gives your function the FormData for you. The form also works as a plain HTML form if JavaScript is slow to load.
useActionState: results, errors and pending
An action usually needs to give something back, like an error message or a success note. useActionState connects an action to state:
import { useActionState } from 'react';
async function signUpAction(previousState, formData) {
const email = formData.get('email');
const password = formData.get('password');
if (password.length < 8) {
return { error: 'Your password needs at least 8 characters.' };
}
const response = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) return { error: 'Could not create the account. Please try again.' };
return { success: true };
}
function SignUpForm() {
const [state, formAction, isPending] = useActionState(signUpAction, {});
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
<button disabled={isPending}>{isPending ? 'Creating...' : 'Create account'}</button>
{state.error && <p role="alert">{state.error}</p>}
{state.success && <p role="status">Account created. Welcome!</p>}
</form>
);
}
- The action receives the previous state as its first argument, and the
FormDataas the second. - Whatever the action returns becomes the new
state. useActionStategives you[state, formAction, isPending]. UseformActionin the form, andisPendingto disable the button.
Forms that need validation, errors and a loading state become much shorter. Remember, as always, to validate again on the server.
useFormStatus: a button that knows
A submit button deep in a component tree can know whether its parent form is sending, without any props, using useFormStatus from react-dom:
import { useFormStatus } from 'react-dom';
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? 'Sending...' : children}</button>;
}
// use it inside any form
<form action={subscribe}>
<input name="email" type="email" />
<SubmitButton>Subscribe</SubmitButton>
</form>
It must be used in a component that is inside the form, not in the component that renders the form itself.
useOptimistic: update at once, fix later
When someone clicks a heart or sends a chat message, waiting for the server makes the page feel slow. An optimistic update shows the expected result immediately, and returns to the real state if the request fails. useOptimistic makes this easy:
import { useOptimistic, useState } from 'react';
function Messages({ initialMessages, sendToServer }) {
const [messages, setMessages] = useState(initialMessages);
const [optimistic, addOptimistic] = useOptimistic(
messages,
(current, newText) => [...current, { id: 'temp-' + newText, text: newText, sending: true }]
);
async function send(formData) {
const text = formData.get('text');
addOptimistic(text); // appears at once, marked "sending"
const saved = await sendToServer(text); // real request
setMessages((current) => [...current, saved]);
}
return (
<div>
<ul>
{optimistic.map((m) => (
<li key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>{m.text}</li>
))}
</ul>
<form action={send}>
<input name="text" required />
<button>Send</button>
</form>
</div>
);
}
The optimistic value is used only while the action runs. When it finishes, React uses the real messages again. If the request failed, the temporary message simply disappears, since the real state never included it. Show an error message to explain.
The use API
use is a new function that reads a value that may not be ready yet. It can read a promise, and it can read context. When given a promise, it pauses the component until the promise is done, and shows the nearest Suspense fallback meanwhile.
import { use, Suspense } from 'react';
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // waits here
return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}
function Post({ commentsPromise }) {
return (
<Suspense fallback={<p>Loading comments...</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}
An important point: the promise should be created outside the rendering component, for example by a parent, a router loader, a framework or a data library, and passed down. If you create a new promise inside the component that calls use, it will create a new one every render and loop. For most apps, a library such as TanStack Query or a framework handles this for you. Unlike other hooks, use can be called inside an if or a loop. When reading context, use(MyContext) works like useContext.
ref as a normal prop
Before React 19, passing a ref through your own component needed forwardRef. Now ref is simply a prop that function components can receive:
function TextInput({ ref, label, ...rest }) {
return (
<label>
{label}
<input ref={ref} {...rest} />
</label>
);
}
Refs can also have a cleanup function now: a ref callback may return a function that React calls when the element is removed.
Document metadata in components
You can now write title, meta and link tags anywhere in your components, and React moves them into the head of the page for you:
function ProductPage({ product }) {
return (
<article>
<title>{product.name} | My Shop</title>
<meta name="description" content={product.summary} />
<h1>{product.name}</h1>
</article>
);
}
This replaces the need for a helper library for simple cases. Remember that a page drawn only in the browser is harder for search engines to read, so for pages that must rank well, think about server rendering.
Smaller improvements
- Context as a provider: you can write
<ThemeContext value="dark">instead of<ThemeContext.Provider value="dark">. - Better error reporting: clearer messages, and options to report errors caught and not caught.
- Support for custom elements (web components).
- Stylesheets and scripts: better control of loading order and avoiding duplicates.
- Preloading helpers such as
preloadandpreconnectinreact-dom.
The React Compiler and Server Components
Two bigger ideas go with this release. The React Compiler is a build tool that adds memoisation for you, so you write less useMemo and useCallback. Server Components let some components run only on the server and send results to the browser, which is used by frameworks such as Next.js. You do not need either to start. Learn them after you are comfortable with everything in this course.
Should you use these features now?
- If you start a new project on React 19, yes: form actions and
useActionStatemake forms shorter. - If your project uses an older version, the old ways (
onSubmit,useState) still work, and you can upgrade later. - Keep using a data library for server data.
useis a building block, not a replacement for caching.
Common mistakes
- Calling
useFormStatusin the same component that renders the form. It must be in a child. - Creating the promise inside the component that calls
use. - Forgetting
nameon inputs, so theFormDatahas no values. - Trusting only the client check. Always validate on the server.
- Using optimistic updates for actions that often fail.
Your task
- Make a newsletter form with a form action and no
onSubmit. - Rewrite your sign-up form with
useActionStateand show the pending state and error. - Add a
SubmitButtonthat usesuseFormStatus. - Add an optimistic message list that shows new messages at once.
How to check it worked
- The form works with no
preventDefaultcode. - The button is disabled and shows “Creating...” while the request runs.
- A new message appears immediately, and disappears if you make the fake request fail.
Summary
- Form
actiontakes a function that receives theFormData.useActionStateadds result, error and pending state. useFormStatuslets a child button know that its form is sending.useOptimisticshows expected results at once.usereads a promise or context, and works withSuspense.refis now a normal prop.- You can render
titleandmetatags in components. The old ways still work.