DEV Community

Cover image for React 19's useOptimistic Fixed My Instant UI. Then Combining It With useActionState Broke My Reset Button
Shubhra Pokhariya
Shubhra Pokhariya

Posted on • Originally published at shubhra.dev

React 19's useOptimistic Fixed My Instant UI. Then Combining It With useActionState Broke My Reset Button

Reveals why optimistic state is derived, not owned

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
  });
}
Enter fullscreen mode Exit fullscreen mode

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?);
Enter fullscreen mode Exit fullscreen mode

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 (
    <div>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id}>{c.text}</li>
        ))}
      </ul>
      <form action={handleSubmit}>
        <input name="comment" disabled={isPending} />
        <button disabled={isPending}>Post</button>
        <button type="button" onClick={handleReset} disabled={isPending}>
          Reset
        </button>
        {state.error && <p role="alert">{state.error}</p>}
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 (
    <CommentBox
      comments={comments}
      onConfirmed={(comment) =>
        setComments((current) => [...current, comment])
      }
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (32)

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Good debugging post as usual! 😀 Small differences in how functions behave can be surprisingly hard to learn. Following your last post about useActionState, this time I got to learn about useOptimistic. Thank you!

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks so much! 😊 It really means a lot that you've been following the series.

Those small behavioral differences are exactly the kind of things I wanted to focus on because they're easy to miss until they show up in a real app. I'm glad you found it useful!

Collapse
 
vinimabreu profile image
Vinicius Pereira

The three mistakes have one root worth naming: optimistic state is derived, not owned. Forgetting onConfirmed is failing to update the source it derives from, the manual reset is writing to a projection, and generating an id inside the updater assumes it runs once like an event handler rather than as a derivation React is free to re-run. If you catch yourself resetting it, writing to it, or minting values in it, you have started treating a view as a store.

One practical follow-on to the temp id: have the server accept the client-generated uuid instead of assigning its own. Then the optimistic row and the confirmed row share a key, the swap reconciles invisibly instead of flashing a duplicate, and you get idempotency on retry for free, since a resubmitted comment with the same id is a no-op rather than a second comment.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Vinicius! I really like the way you framed it as "derived, not owned." That ties the three mistakes together really well.

The client-generated UUID suggestion is a great extension too. Letting the server accept it keeps the identity consistent all the way through, and the idempotency benefit on retries is a nice bonus.

Collapse
 
vinimabreu profile image
Vinicius Pereira

Happy it was useful. One edge on the client-generated id: the server has to make the insert idempotent too, not just accept the id. INSERT ... ON CONFLICT (id) DO NOTHING plus a read-back, so a retry returns the original row instead of a unique violation the user reads as a real error. And keep identity the only thing the client owns. Timestamps and author stay server-assigned.

Also worth generating it as UUIDv7 rather than v4. v7 is time-ordered, so the optimistic row sorts into its final position right away. With v4 you fall back to sorting by timestamp, and the client and server clocks disagree just enough that the row can jump when the confirmed version lands. crypto.randomUUID() gives you v4, so v7 needs a small helper.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

Exactly. That completes the picture nicely. Keeping the client-generated ID stable solves the identity side, but the backend still has to make retries idempotent for the whole flow to hold together.

I also hadn't considered the UUIDv7 ordering angle. That's a really elegant way to keep the optimistic and confirmed states aligned.

"Derived, not owned" still feels like the thread that ties all of this together. Thanks for expanding on it, Vinicius!

Thread Thread
 
vinimabreu profile image
Vinicius Pereira

Glad it landed. Good post to think out loud in.

Collapse
 
muhammad_lutfimuzaki_ profile image
Muhammad Lutfi Muzaki

This is an excellent point by Vinicius. Treating optimistic state as a derived projection rather than a source of truth is a key mindset shift. Another challenge is managing race conditions when multiple actions are in flight - e.g., if you submit comment A, then comment B before A completes. useOptimistic handles this internally by queueing updates, but if the server response for A fails and B succeeds, reconciling that state back to the 'true' state from useActionState requires careful state machine design. Client-generated UUIDs definitely simplify the reconciliation here.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, that's a good edge case to raise. One small distinction though: the queueing here is actually coming from useActionState's dispatchAction, not useOptimistic. I covered that in the AbortController section: dispatches are processed in the order they arrive rather than racing each other.

In this particular CommentBox, the A-then-B scenario also can't happen through the UI because the input and submit button are disabled while isPending is true. And since both submissions would go through the same formAction, useActionState would process them sequentially anyway.

So there's no A-fails/B-succeeds reconciliation race in this specific example. The broader concern is definitely relevant for optimistic updates that involve multiple independent actions, where nothing is serializing the requests for you. That's where the reconciliation and state-machine complexity becomes much more interesting.

And yes, the client-generated ID still helps with identity and retry reconciliation, as Vinicius pointed out earlier.

Collapse
 
glenallen profile image
Glen Allen

Great breakdown. At IT Path Solutions, we've seen that optimistic UI patterns deliver the best experience when they're paired with careful state transition testing. Most production issues tend to appear where multiple hooks and async workflows intersect, making end-to-end validation just as important as individual feature testing.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Glen! I agree. Neither hook was the problem on its own. It was the interaction between them that exposed the edge case, and that was the part I found most interesting to debug.

Collapse
 
mudassirworks profile image
Mudassir Khan

"optimistic state is derived, not owned" is the mental model I wish I'd had earlier. the trap we hit was treating the optimistic setter like a manual state toggle and expecting it to survive form resets. it doesn't — once the transition settles, the value prop overwrites it, which is exactly correct behavior but feels like a bug when you're not expecting it.

the manual rollback pattern is gnarly at scale too. once you have more than two in flight optimistic updates at once, the rollback order starts mattering and things get messy fast.

how are you handling the case where useActionState's reset and useOptimistic's revert disagree on timing?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Exactly, Mudassir. That's how I think about it now. In this particular example, I avoid the timing conflict rather than trying to coordinate two resets.

Reset is disabled while isPending is true, so by the time it can actually run, the Transition has already settled and optimisticComments is already back to comments. There's nothing left for useOptimistic to reset at that point. formAction(null) only clears the useActionState result, which is the one piece of state that was actually still holding onto something.

If the two ever did need to reset at genuinely different moments, I'd rather model that as two explicit signals than add a second reset path into useOptimistic and end up fighting a lifecycle it's already managing correctly on its own.

And yeah, agreed on the manual rollback problem. Past a couple of updates in flight, you're basically hand-building the reconciliation useOptimistic gives you for free. That's really where "derived, not owned" earns its keep, once you stop treating the optimistic setter like a second setState and let it just be a projection of comments.

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala

Shubhra, I don't know React deeply enough yet to say much on the technical side 😄 but I've been enjoying how you walk through the mistakes you ran into instead of just showing the final working version. It makes the debugging process interesting to follow 😀

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you so much, Hemapriya! 😊 That really means a lot.

I'm glad it was still easy to follow even without a deep React background. That was one of my goals while writing it. I also enjoy walking through the mistakes because they're often where the real learning happens. Thanks for reading and for sharing that!

Collapse
 
frank_signorini profile image
Frank

Super interesting read! I'm curious if you considered using a key prop on the form itself

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thanks, Frank! I'm really glad you enjoyed the post. 😊

Good question. I did consider it, but a key on the <form> itself wouldn't affect useActionState or useOptimistic since those hooks live on CommentBox, not the form. The remount trade-off I described only comes into play if CommentBox itself is remounted.

Collapse
 
citedy profile image
Dmitry Sergeev

man useOptimistic is such a lifesaver, but yeah the state reset logic with useActionState is always a headache lol

Collapse
 
shubhradev profile image
Shubhra Pokhariya

That was my first impression too. In the end, I realized I only needed to reset one piece of state.

Collapse
 
citedy profile image
Dmitry Sergeev

did the same thing with useOptimistic last week and spent two hours debugging the state reset lol. definitely a weird quirk with how it interacts with actions.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Yeah, that debugging session is basically what pushed me to write this up. It feels like a quirk at first, but it's really just state living on the component that owns the hook. Once I traced where the state lived, the behavior stopped being surprising.

Collapse
 
elsie-rainee profile image
Elsie Rainee

Awesome post! 🔥 Loved how you explained everything so simply. Super helpful! 🙌

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Thank you so much Elsie! 😊 I'm really glad you enjoyed it. I always try to keep things as simple and practical as possible. Thanks for reading!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.