I had a <form> in a side project that I rewrote four times last year, and every version was a little worse than the one before it. Loading flag here, error state there, a useEffect to reset the input after a successful submit, a ref so I could focus the field again. None of it was hard. All of it was annoying. By the fourth pass I had a 60-line component that did roughly what a plain HTML form did in 1998, except mine could also get stuck in a spinner if the network hiccuped.
Then I moved the project to React 19 and deleted about half of it. This isn’t a “everything changed” post, because most of my React looks the same. But forms, optimistic updates, and reading async data got genuinely simpler, and I want to show you the before-and-after on the parts I actually use.
The thing I kept getting wrong in React 18
Here’s the shape of the problem. In React 18, a submit handler that talks to the network needs you to track three things by hand: whether it’s pending, whether it failed, and what to do after it succeeds. Miss one and the UI lies to the user.
// React 18
function CommentForm({ postId }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const inputRef = useRef(null);
async function handleSubmit(e) {
e.preventDefault();
setPending(true);
setError(null);
try {
await postComment(postId, inputRef.current.value);
inputRef.current.value = "";
} catch (err) {
setError(err.message);
} finally {
setPending(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} name="comment" disabled={pending} />
<button disabled={pending}>{pending ? "Posting..." : "Post"}</button>
{error && <p className="err">{error}</p>}
</form>
);
}
It works. I shipped versions of this for years. But the bookkeeping is all mine, and the bugs live in the gaps: forgetting to clear the error on retry, leaving the button disabled after an exception you didn’t expect, racing two submits because nothing stops the second one.
useActionState: the form state I stopped wiring by hand
React 19 ships an actions model and a hook called useActionState that owns the pending and result state for you. You give it an async function; it gives you back the latest state, a wrapped action, and a pending boolean.
// React 19
import { useActionState } from "react";
function CommentForm({ postId }) {
const [error, submitAction, pending] = useActionState(
async (_prev, formData) => {
try {
await postComment(postId, formData.get("comment"));
return null; // no error
} catch (err) {
return err.message;
}
},
null
);
return (
<form action={submitAction}>
<input name="comment" disabled={pending} />
<button disabled={pending}>{pending ? "Posting..." : "Post"}</button>
{error && <p className="err">{error}</p>}
</form>
);
}
No useState for pending. No useRef. The <form action={...}> prop is the part that surprised people: forms can now take a function directly, and React resets uncontrolled fields on a successful action so I stopped manually clearing the input. The official React 19 release notes walk through the whole actions model, and the useActionState reference is worth a read because the argument order trips people up the first time.
The win isn’t fewer lines, although it is fewer lines. The win is that pending state and the form are wired together by the framework, so the two can’t drift apart. I covered a related cleanup in my post on why I stopped writing forwardRef, and it’s the same theme: React 19 quietly absorbed a chunk of plumbing I used to own.
useOptimistic, or how I stopped faking responsiveness badly
The other thing I always hand-rolled was optimistic UI. Show the comment immediately, then reconcile when the server answers. My old approach kept a shadow copy of the list in state and a mess of logic to roll it back on failure. It worked until it didn’t, usually when two actions overlapped.
useOptimistic gives you a temporary state that automatically reverts when the surrounding action finishes or throws.
// React 19
import { useOptimistic } from "react";
function Comments({ comments, postId }) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(state, newText) => [...state, { id: "temp", text: newText, sending: true }]
);
async function action(formData) {
const text = formData.get("comment");
addOptimistic(text);
await postComment(postId, text);
}
return (
<>
<ul>{optimistic.map(c => (
<li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>{c.text}</li>
))}</ul>
<form action={action}><input name="comment" /><button>Post</button></form>
</>
);
}
When the action resolves, the optimistic entry drops and the real list (refreshed however you refresh it) takes over. When it throws, React rolls the optimistic state back for you. I wrote a longer field report on this in six months with useOptimistic, including the cases where it bit me, so I won’t repeat all of it here.
The use hook and reading promises without useEffect
use is the one I’m still careful with. It lets a component read a promise (or context) during render, and it suspends until the promise resolves. Paired with a Suspense boundary, it replaces a lot of the useEffect plus loading-flag dance for data you pass down.
// React 19
import { use, Suspense } from "react";
function Profile({ userPromise }) {
const user = use(userPromise); // suspends until resolved
return <h1>{user.name}</h1>;
}
function Page({ userPromise }) {
return (
<Suspense fallback={<p>Loading...</p>}>
<Profile userPromise={userPromise} />
</Suspense>
);
}
The catch: you don’t want to create that promise inside the component on every render, or you’ll fetch in a loop. You create it higher up, or in a server component, and hand it down. The use reference is blunt about the rules, and I’d read it twice before reaching for this in anything load-bearing. I got it wrong for about a week before the loop made sense to me.
One more thing that use does, which is easy to miss: it can read context, and unlike useContext, you’re allowed to call it conditionally. That sounds like a footnote until you have a component that only needs theme context on one branch and you’ve been pulling it unconditionally at the top for years out of habit. Small thing. It adds up.
useFormStatus: the prop drilling I stopped doing
There’s a quieter hook that pairs with all of this: useFormStatus. It lets a child component read the pending state of the form it sits inside, without you threading a pending prop down through every layer. The classic case is a submit button that lives three components deep in some design-system wrapper.
// React 19
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>;
}
SubmitButton doesn’t take any props about loading state. It reads it from the nearest parent <form>. The first time I deleted a pending prop that had been passed through four components, I actually said “oh, thank god” out loud. It’s a small ergonomic win, but prop drilling boolean state is the kind of thing that quietly makes a codebase feel heavier than it is.
The catch worth knowing: useFormStatus only reads the form it’s a descendant of, and the button has to be inside that <form>, not rendering it. If you put the hook in the same component that renders the form, you get nothing back, because it’s looking at a parent that isn’t there. I tripped on that exactly once.
What I’d actually adopt first
If you’re upgrading an existing app, don’t rewrite everything. Start with useActionState on your noisiest forms, the ones with the most hand-managed pending and error state. That’s where the cleanup pays for itself immediately and the risk is low, because the behavior is the same and you’re deleting code rather than adding it.
Add useOptimistic only where you already fake responsiveness, and skip it where a plain spinner is honest enough. Leave use and Suspense-driven data fetching for new code or a deliberate refactor, because the promise-creation rule is easy to trip over.
One caveat I’d flag before you go all-in: these patterns assume you’re comfortable with the actions model, and if your app leans heavily on a data library like TanStack Query or Redux Toolkit Query, there’s some overlap to think through. The hooks don’t replace those tools, and bolting both onto the same flow can muddy who owns the loading state. I’d pick one source of truth per interaction rather than letting React’s form actions and your query library both try to manage pending state. When they fight, you get flicker, and flicker is worse than a slightly more verbose component.
A concrete thing to do this week: open the form in your app that has the most useState calls around submitting, and try porting just that one to useActionState. You’ll know within an hour whether the trade feels right. If you want to see how I think about adopting framework features without betting the whole codebase on them, that’s most of what I write about over on my work page. React 19 didn’t reinvent how I build. It just took back some chores I was tired of doing by hand.