renderpx
Theme: auto

Optimistic Updates

Update the UI immediately on user action, then sync with the server. Roll back cleanly if the request fails.

The problem I keep seeing

Every like, follow, or toggle that waits for the server before updating the UI feels sluggish. On slow or flaky networks, users assume the tap didn’t register and tap again. The fix is to show the new state immediately and reconcile with the server in the background—optimistic updates.

The catch: if the request fails, you must roll back. And if you’re using a cache (e.g. React Query), the optimistic change has to live in that cache so refetches and other components stay in sync.

Naive approach

Wait for the server, then update local state. Simple, but every interaction blocks on the network.

tsx
Loading...

First improvement

Flip state immediately, then fire the request. On error, set state back. The UI feels instant; the logic is still local to one component.

Click Like — instant toggle; failure would roll back
Likes: 0

Button flips immediately (optimistic); request simulates 600ms. In production, failure would roll back.

Optimistic setState + rollbacktsx
Loading...

Why this helps: Users get immediate feedback. Rollback on error keeps the UI truthful when the server rejects the action.

Remaining issues

  • Cache vs local state: If the like count comes from React Query (or similar), updating only local state means the next refetch overwrites your optimistic change. The cache is the source of truth for the rest of the app.
  • Racing refetches: A background refetch that finishes after you’ve applied an optimistic update can overwrite it with stale server data if you don’t cancel or sequence refetches.
  • Multiple mutations: Rapid clicks (e.g. Like → Unlike) can complete out of order; you need a consistent rollback and invalidation strategy.

Production pattern

Use React Query’s useMutation with onMutate, onError, and onSettled: snapshot the cache, apply the optimistic change to the cache, roll back from the snapshot on error, and invalidate on settle so the server remains the source of truth.

tsx
Loading...

cancelQueries in onMutate prevents an in-flight refetch from overwriting your optimistic update. onSettled invalidates so the next read gets fresh data from the server.

When I use this

  • Use: Reversible, low-stakes actions (like, follow, bookmark, toggle settings). High frequency, user expects instant feedback.
  • Skip: Destructive or high-stakes actions (delete, payment, publish). A rollback after “Success” is worse than a spinner; use loading state and confirm only after the server responds.

Decision

If rolling back would confuse or alarm the user, don’t be optimistic—show loading and wait for the server.

Gotchas

  • Racing mutations: Disable the button with mutation.isPending (or equivalent) so the user can’t fire a second mutation before the first settles.
  • Dependent data: If “user A followed user B” affects both profiles, invalidate all related query keys in onSettled so every view stays in sync.
  • Toast on rollback: When you roll back, show a short toast (“Couldn’t update. Try again.”) so the user knows the action didn’t stick.

Data Fetching & Sync framework → · All patterns