React 19 added a group of features that make asynchronous work and forms much easier. Before, handling a form submit meant preventDefault, a pile of state variables (isLoading, error, result) and careful resetting. These features move most of that bookkeeping into React. They are especially nice with servers, but every example below runs in the browser using a fake server delay, so you can try them in the editor.
Form actions
In HTML, a form's action is a URL. In React 19 you can pass a function instead. When the form is submitted, React calls your function with the form's FormData, and afterwards it resets uncontrolled fields for you. No preventDefault, no controlled inputs needed:
import { useState } from "react";
function App() {
const [subscribed, setSubscribed] = useState("");
function subscribe(formData) {
const email = formData.get("email");
setSubscribed(email);
}
return (
<form action={subscribe}>
<input name="email" type="email" placeholder="Email" required />
<button>Subscribe</button>
<p>Subscribed: {subscribed || "nobody yet"}</p>
</form>
);
}
export default App;Subscribe
Subscribed: asha@example.comThe action ran with the form's data. (Actions can also be async, which is the main point: React tracks them as they run.) Fields are read by their name attribute, as in the earlier FormData example. After the action finishes, React resets the form, so the email box is empty again.
useActionState: result and pending status in one hook
useActionState wraps an action and keeps track of its result. You give it a function (previousState, formData) => newState and an initial state; it returns:
state: whatever your action last returned (initially the initial state),formAction: the function to pass to the form'sactionprop,isPending:truewhile the action is running.
import { useActionState } from "react";
function fakeSubscribe(email) {
return new Promise((resolve, reject) =>
setTimeout(() => (email.endsWith("@example.com") ? resolve() : reject(new Error("Unsupported domain"))), 300));
}
async function subscribeAction(previousState, formData) {
const email = formData.get("email");
try {
await fakeSubscribe(email);
return { ok: true, message: "Subscribed " + email };
} catch (error) {
return { ok: false, message: error.message };
}
}
function App() {
const [state, formAction, isPending] = useActionState(subscribeAction, { ok: null, message: "" });
return (
<form action={formAction}>
<input name="email" type="email" placeholder="Email" required disabled={isPending} />
<button disabled={isPending}>{isPending ? "Subscribing..." : "Subscribe"}</button>
{state.message && <p role="status">{state.message}</p>}
</form>
);
}
export default App;Subscribe
Subscribed asha@example.comCompare it with the submit-status example in the forms lesson: there is no preventDefault, no useState for the status, and no try/finally to turn the pending flag off. The action returns a plain object describing the outcome, and the component renders from state and isPending. Errors you expect (like validation) are returned as state rather than thrown; throwing is for unexpected failures, which an error boundary can catch.
useFormStatus: a button that knows the form is busy
Often the submit button lives in its own reusable component. useFormStatus (imported from react-dom) tells a component inside a form whether that form is pending, so you do not need to pass isPending down as a prop:
import { useFormStatus } from "react-dom";
function SubmitButton({ children }) {
const { pending } = useFormStatus(); // reads the parent form's status
return <button disabled={pending}>{pending ? "Saving..." : children}</button>;
}
async function save(formData) {
await new Promise(resolve => setTimeout(resolve, 300));
console.log("saved:", formData.get("title"));
}
function App() {
return (
<form action={save}>
<input name="title" defaultValue="My note" />
<SubmitButton>Save note</SubmitButton>
</form>
);
}
export default App;Save note
CONSOLE
saved: My noteSubmitButton can be used in any form in the app and always reflects the right status. Remember the rule: useFormStatus must be called in a component rendered inside the <form>; calling it in the component that renders the form itself will not work.
useOptimistic: show the result before the server answers
When a user adds a comment, waiting 500 ms for the server before anything appears feels slow. An optimistic update shows the expected result immediately and corrects it if the request fails. useOptimistic(realState, updateFn) returns the value to display and a function to add a temporary change; the temporary change disappears automatically when the real state arrives.
import { useOptimistic, useState } from "react";
function App() {
const [messages, setMessages] = useState(["First message"]);
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(current, newText) => [...current, newText + " (sending...)"]
);
async function send(formData) {
const text = formData.get("text");
addOptimistic(text); // shows immediately
await new Promise(resolve => setTimeout(resolve, 400)); // the "server"
setMessages(current => [...current, text]); // the real result replaces the temporary one
}
return (
<div>
<ul>
{optimisticMessages.map((message, index) => <li key={index}>{message}</li>)}
</ul>
<form action={send}>
<input name="text" placeholder="Message" />
<button>Send</button>
</form>
</div>
);
}
export default App;First message
Hello (sending...)
SendThe result above was captured 100 ms after submitting, so the new message appears with "(sending...)". Wait 400 ms in the editor and the temporary line is replaced by the confirmed one. If the request failed, you would not update messages, and the optimistic line would simply vanish. Optimistic updates are used in "like" buttons, to-do lists and chat inputs, anywhere latency is the main annoyance.
useTransition: keep the screen responsive
A transition marks a state update as non-urgent. React keeps the current screen interactive while it prepares the new one. In React 19, startTransition can also take an async function, and isPending tells you it is running:
import { useState, useTransition } from "react";
function App() {
const [isPending, startTransition] = useTransition();
const [name, setName] = useState("nobody");
function loadName() {
startTransition(async () => {
await new Promise(resolve => setTimeout(resolve, 400));
startTransition(() => setName("Asha")); // state updates after await need their own transition
});
}
return (
<div>
<button onClick={loadName} disabled={isPending}>Load name</button>
<p>{isPending ? "Loading..." : "Name: " + name}</p>
</div>
);
}
export default App;Load name
Loading...The text shows "Loading..." while the transition runs, and "Name: Asha" afterwards. The result above was captured 100 ms after the click, so it shows the loading state. Form actions use transitions behind the scenes, which is why isPending works for them.
use: reading a promise or context during render
use is a special API (not quite a hook: it may be called in conditions and loops) that reads a resource during rendering. Given a promise, it suspends the component until the promise resolves; a Suspense boundary above shows a fallback in the meantime. Given a context, it reads the context, like useContext.
import { Suspense, use } from "react";
function loadUser() {
return new Promise(resolve => setTimeout(() => resolve({ name: "Asha Rao", role: "Developer" }), 300));
}
const userPromise = loadUser(); // created once, outside any component
function Profile() {
const user = use(userPromise); // suspends until resolved
return <p>{user.name}, {user.role}</p>;
}
function App() {
return (
<Suspense fallback={<p>Loading profile...</p>}>
<Profile />
</Suspense>
);
}
export default App;Asha Rao, DeveloperProfile is written as if the data were already there. While waiting, React shows the fallback; when the promise resolves, it renders Profile. The crucial rule: the promise must not be created during the render, or each render would make a new promise and wait forever. Create it outside the component (as here), pass it down as a prop from a parent that created it earlier, or get it from a library or framework that caches promises. This is why use is mostly seen with frameworks and data libraries. For a plain client-side app, the effect-based pattern from the fetching lesson, or TanStack Query, remains the practical choice; use is the building block those tools use. If the promise rejects, the nearest error boundary shows an error (see the next section of the course).
When to use these features
| Need | Tool |
|---|---|
| Handle a form submit with FormData | <form action={fn}> |
| Result and pending state of an action | useActionState |
| A submit button that knows the form is busy | useFormStatus |
| Show the expected result immediately | useOptimistic |
| Mark an update as non-urgent, track it | useTransition |
| Read a promise or context during render | use |
Controlled inputs and onSubmit remain perfectly valid. Reach for actions when you want less boilerplate for the submit-pending-result flow, or when your framework (such as Next.js with Server Actions) expects them. Many of these features really shine with server rendering, where an action can run on the server; the principles you learned here stay the same.
Common mistakes
- Calling
useFormStatusin the same component that renders the<form>. - Forgetting that actions reset uncontrolled form fields after they finish; return the entered value in state if you want to keep it displayed.
- Creating the promise inside the component before
use, causing an endless suspend loop. - Using
usewithout a Suspense boundary and an error boundary above it. - Throwing for expected errors (like invalid input) instead of returning them as state.
- Updating state after an
awaitinside a transition without wrapping it in its ownstartTransition.
Exercise
- Rewrite the sign-up form from the forms lesson with
useActionState, returning field errors in the state. - Add a "Like" button using
useOptimisticthat increments the count at once and rolls back on a simulated failure. - Create a
SubmitButtoncomponent withuseFormStatusand use it in two different forms. - Pass the promise for the profile down from
Appas a prop, and render twoProfilecomponents with different promises, each in its own Suspense boundary.
Key points
- Pass a function to a form's action prop: React calls it with the FormData when the form is submitted, and resets uncontrolled fields afterwards.
- useActionState(action, initialState) returns [state, formAction, isPending] so you can show the result and a pending indicator without extra state.
- useFormStatus (from react-dom) lets a button inside a form know whether the form is pending; useOptimistic shows the expected result while the server request is still running.
- use(promise) reads a promise during render and suspends until it resolves, with a Suspense boundary showing the fallback; the promise must be created outside the render.