React 19's useOptimistic Fixed My Instant UI. Then Combining It With useActionState Broke My Reset Button
Part 1 was about waiting well. This one is about not waiting at all, and about a couple of mistakes I made along the way that are worth walking through in the open. If you read Part 1 on useActionState, you already know the shape of the problem it solves: three hand-rolled state variables, a try/catch/finally, and a pending flag you're hoping stays in sync with reality. useActionState fixes that for the "wait for the server, then show the result" case, and I closed that piece by saying useOptimistic was the next hook worth learning. This is that piece. But some interactions never wanted to wait in the first place. A comment posts. A like fires. A checklist item gets ticked off. The user expects to see the outcome the instant they act, not the instant the network agrees with them. That's the gap useOptimistic fills. And building a real example that combines it with useActionState is what exposed two mistakes I'd made without realizing it. The old trick, and why it quietly lied Before this hook existed, "instant" UI usually meant something like this: function handleLike() { setLiked(true); // update now, hope the request agrees later fetch("/api/like", { method: "POST" }).catch(() => { setLiked(false); // manually undo it if the request fails }); } It works, until it doesn't. Forget the catch block and a failed request leaves the UI lying to the user forever. Get a second click in before the first request resolves and you're racing two local booleans against two network calls with no ordering guarantee between them. I shipped a version of this on a follow button once and spent an evening figuring out why the count would occasionally drift by one after a flaky connection. The state wasn't wrong because of a typo. It was wrong because nothing was actually managing the relationship between what I was showing and what was confirmed. useOptimistic manages exactly that relationship, and it does it without you writing a single line of manual rollback logic, as long as you actually wire it up right. Keep reading, because I didn't, on the first pass. What it does, briefly const [optimisticState, setOptimistic] = useOptimistic(value, reducer?); value is your real, confirmed state, the thing you'd render if nothing were in flight. optimisticState matches value right up until you call the setter inside a Transition, at which point it temporarily reflects whatever you passed in. Once the Transition settles, optimisticState collapses back to value. Not a value you have to reset. Not a flag you have to flip back. It falls back on its own, because that was always the only thing it was ever equal to once nothing's pending. I go through the full API, the updater-function-versus-reducer decision, and the edge cases in the complete useOptimistic tutorial, so I won't repeat that ground here. What I want to get into is what happens when useActionState and useOptimistic end up in the same form, specifically around resetting it, because that's where things stopped being simple. Combining them: a comment box Here's the version I ended up with, after fixing what I got wrong the first time around. import { useActionState, useOptimistic, startTransition } from "react"; function CommentBox({ comments, onConfirmed }) { async function postComment(previousState, formData) { if (formData === null) { return { error: null }; } const text = formData.get("comment"); if (!text?.trim()) { return { error: "Comment can't be empty." }; } const saved = await saveComment(text); startTransition(() => onConfirmed({ id: saved.id, text })); return { error: null }; } const [state, formAction, isPending] = useActionState(postComment, { error: null, }); const [optimisticComments, addOptimisticComment] = useOptimistic(comments); async function handleSubmit(formData) { const text = formData.get("comment"); const id = crypto.randomUUID(); addOptimisticComment((current) => [...current, { id, text }]); return formAction(formData); } function handleReset() { startTransition(() => formAction(null)); } return ( {optimisticComments.map((c) => ( {c.text} ))} Post Reset {state.error && {state.error}} ); } CommentBox takes an onConfirmed callback the same way the upvote example in the full tutorial does, and it matters for the same reason. useOptimistic never updates the real comments prop for you. Something outside the hook has to. Once saveComment resolves, postComment calls onConfirmed with the confirmed comment, and the parent is expected to fold that into its own state, the same pattern as this: function CommentThread({ postId }) { const [comments, setComments] = useState(initialComments); return ( setComments((current) => [...current, comment]) } /> ); } Skip that wiring and you'll watch every comment you post disappear the moment the Transition settles, even though the server accepted it. That's not a hypothetical. It's the exact "shows you the future, doesn't make it real" mistake the full tutorial calls out, and it's worth restating here because it's easy to leave out of an example like this without noticing. Notice too that onConfirmed is called inside its own startTransition, nested after the await. That's not decoration. If you update real state after an await inside a reducerAction, the docs are specific that it needs its own Transition wrapper, the same requirement the tutorial's edge cases section covers for the LikeButton example. One more small thing worth calling out, because I got it wrong on an earlier draft of this same example: the id for each optimistic comment is generated with crypto.randomUUID() in handleSubmit, before it ever reaches the updater passed to addOptimisticComment. Not inside the updater itself. The reducer or updater you give useOptimistic has to be pure, the docs say so directly, and something like Date.now() called from inside it isn't. React can invoke that function more than once for the same update, and a fresh id each time it's called turns one comment into two different keys in your list. Why remounting breaks more than the form useActionState doesn't give you a way to clear its own state automatically. One common fix is bumping a key prop to force the whole component to remount, and on its own that's a reasonable option. It does reset the form. This is a different thing from requestFormReset, which React 19 also ships. That one clears uncontrolled DOM field values, not the state useActionState is holding on to, so it solves a narrower problem than the one we're talking about here. The remount trick has a cost that doesn't show up until useOptimistic enters the picture. A remount doesn't just clear useActionState's result. It tears down and rebuilds the whole component tree underneath that key, and any useOptimistic state riding along in that same component gets torn down with it. If a Transition using that optimistic state hadn't settled yet, the optimistic item doesn't get replaced by the real one. It just vanishes. My first fix for this, on an earlier pass, was to give the optimistic side its own reset branch too, mirroring the reset-signal pattern useActionState's own docs recommend. Something like: function commentReducer(current, action) { if (action.type === "reset") return []; return [...current, action.item]; } That looks reasonable and it's wrong in a way that only shows up once you trace what current actually holds. current isn't just the pending draft, it's the whole list, confirmed comments included. Returning [] unconditionally means clicking Reset would briefly wipe out comments that had already been saved, not just cancel whatever hadn't gone through yet. It settles back once the Transition resolves, since comments itself never changed, but for a moment the list a real user is looking at goes empty for no reason they'd understand. The actual fix was realizing useOptimistic doesn't need a reset branch here at all. By the time Reset is even clickable, isPending is false, which means nothing is pending, which means optimisticComments already equals comments. There's nothing left for a reset to clear. The only state that genuinely needed a manual way to reset was useActionState's own return value, the error message, and that's exactly what formAction(null) handles in handleReset above. One piece of state actually needed telling to reset. The other one already had, on its own, which is the whole point of how useOptimistic works in the first place. Resetting while an action is pending, and the AbortController pattern I got wrong There's still a real question buried in here: what happens if Reset gets triggered while a submission is still in flight? In an earlier draft I said React doesn't provide a cancellation mechanism for this. That was wrong, and worth correcting properly instead of quietly editing around it. The current useActionState reference has a section called "Cancelling queued Actions" that threads an AbortController through the payload passed to dispatchAction, letting a new dispatch abort whatever's still pending so it can run immediately instead of waiting in line. It's a documented recipe, not something you'd have to invent from scratch. What I said next holds up better. Whether you should actually reach for that pattern depends on what the pending action does. The docs are blunt about it: aborting an Action isn't always safe, because cancelling the request client-side doesn't undo a mutation that already landed on the server. If saveComment already wrote to the database by the time an abort fires, the comment is still there no matter what the client thinks happened. That's why handleReset above never reaches for AbortController. It calls formAction(null) the same way it calls every other dispatch, and because useActionState processes calls to dispatchAction in the order they arrive, the reset just queues behind whatever's still running instead of racing it. Disabling the Reset button while isPending is true isn't there to prevent a bug. It's there so the person clicking it isn't left wondering why nothing happened yet. If you're building something where cancelling mid-flight genuinely matters, a search-as-you-type action where a stale result arriving late would actively confuse the user is a good example, that AbortController pattern is worth reaching for. For a mutation like posting a comment, letting it finish is the safer default, and as it turns out, the simpler one too. The takeaway useOptimistic asks very little of you on its own: show a value, let it fall back automatically. The two mistakes I walked through here both came from not trusting that. Forgetting to close the loop with onConfirmed meant the hook had nothing real to fall back to. Giving it its own reset branch meant fighting a job it was already doing correctly by itself. Trust the parts that are automatic, wire up the parts that aren't, and reset only the state that actually needs telling. If you haven't read the full breakdown of useOptimistic, including the mistake that makes optimistic updates snap back even on success and when to reach for a reducer instead of an updater function, that's here: React 19 useOptimistic Explained. And if you're doing this inside a real Next.js app with Server Actions, cache invalidation, and error boundaries in the mix, the rollback pattern deep dive covers the parts that only show up once you're past the demo.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to