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.
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.
Button flips immediately (optimistic); request simulates 600ms. In production, failure would roll back.
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.
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
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
onSettledso 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.