DevLift
Back to Blog

React: Optimistic Updates: Make Your UI Feel Instant

Stop making users wait for server round-trips. Learn how to use React's useOptimistic hook and TanStack Query to update your UI instantly and roll back gracefully on failure.

Admin
August 2, 20265 min read0 views

React: Optimistic Updates: Make Your UI Feel Instant

Your users don't care about your server round-trips. They click a button and they expect something to happen — right now. When your UI makes them wait for a server round-trip before acknowledging their action, it feels broken. Optimistic updates fix that by updating the UI immediately, before the server confirms anything, then quietly reconciling once the response comes back.

This pattern is everywhere in production apps — tap a like button on any large social app and watch the count move before the network tab has anything to show. If you're not doing this, your app feels slow even when it isn't.

The Problem

Here's what most React apps do by default. You call an API, wait for the response, then update state. Call it pessimistic updating — you assume something might go wrong and make the user wait to find out.

// ❌ Pessimistic update — user waits for server
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [isLoading, setIsLoading] = useState(false);
 
  async function handleLike() {
    setIsLoading(true);
    try {
      const response = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
      const data = await response.json();
      setLikes(data.likes); // Only updates after server responds
    } catch (error) {
      console.error("Failed to like post");
    } finally {
      setIsLoading(false);
    }
  }
 
  return (
    <button onClick={handleLike} disabled={isLoading}>
      {isLoading ? "..." : `❤️ ${likes}`}
    </button>
  );
}

The button goes dead. The spinner appears. The user wonders if it worked. A round-trip later the count updates. This is friction you're adding for no reason — the vast majority of like requests succeed.

The Pattern

React 19 ships useOptimistic, a hook purpose-built for this. (It spent a while behind the canary flag during 18.x; stable React 19 is where you can actually use it.) You give it the current state and an update function. It gives you back an optimistic state that reflects the update immediately, then drops back to real state once the async action settles.

// ✅ Optimistic update — UI responds instantly
"use client";
 
import { useState, useOptimistic, useTransition } from "react";
 
interface LikeButtonProps {
  postId: string;
  initialLikes: number;
  initialLiked: boolean;
}
 
async function toggleLike(postId: string): Promise<{ likes: number; liked: boolean }> {
  const res = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
  if (!res.ok) throw new Error("Failed to toggle like");
  return res.json();
}
 
export function LikeButton({ postId, initialLikes, initialLiked }: LikeButtonProps) {
  const [state, setServerState] = useState({ likes: initialLikes, liked: initialLiked });
  const [isPending, startTransition] = useTransition();
 
  const [optimisticState, updateOptimistic] = useOptimistic(
    state,
    (currentState, liked: boolean) => ({
      likes: liked ? currentState.likes + 1 : currentState.likes - 1,
      liked,
    })
  );
 
  function handleLike() {
    const newLiked = !optimisticState.liked;
 
    startTransition(async () => {
      // Apply optimistic update immediately
      updateOptimistic(newLiked);
 
      try {
        const result = await toggleLike(postId);
        // Update real state with server's canonical value
        setServerState(result);
      } catch {
        // Nothing to roll back by hand: the optimistic value is discarded
        // when the action ends. But DO tell the user.
        console.error("Like failed");
      }
    });
  }
 
  return (
    <button onClick={handleLike} className={optimisticState.liked ? "liked" : ""}>
      ❤️ {optimisticState.likes}
    </button>
  );
}

Key things happening here:

  • useOptimistic takes your real state and a reducer. The reducer runs immediately when you call updateOptimistic.
  • useTransition wraps the async work. While the action is pending, optimisticState shows the optimistic value. Once it settles, useOptimistic snaps back to whatever state is — which you've updated with the server's real value.
  • The optimistic value has no rollback code because there is nothing to roll back: it only ever existed for the duration of the action.
⚠️

Be precise about the revert, because "reverts on error" is the usual shorthand and it is not what the hook does. Mounted on React 19.2.3 and stepped through: the optimistic value is discarded when the action finishes — success or failure, either way. Three probes, one behaviour.

  • Action rejects, you catch it, real state untouched → UI goes back to 10 / not liked. Looks like a rollback.
  • Action succeeds and you commit setServerState(result) → UI shows the server value. Looks like it "kept" the optimistic update.
  • Action succeeds and you forget to update real state → UI snaps back to 10 anyway, in front of the user.

So useOptimistic is not error handling. It is a temporary overlay with the lifetime of one action. Committing real state on success is your job, and if you skip it the optimistic value visibly evaporates.

One more constraint worth stating: the updater must be called inside a transition or action. Call it from a bare click handler and React logs "An optimistic state update occurred outside a transition or action" and the update does not stick — verified.

How It Flows

Rendering diagram...

The user sees the update at step 2. Everything after that is invisible to them — unless it fails.

Variations

Variation 1: Optimistic List Mutation (Add & Delete)

The like button is simple because it's a single value flip. Lists are trickier — you need to add or remove items optimistically without breaking the existing order or causing layout shifts.

"use client";
 
import { useOptimistic, useTransition, useState } from "react";
 
interface Comment {
  id: string;
  text: string;
  author: string;
  pending?: boolean; // flag for optimistic items
}
 
async function addComment(postId: string, text: string): Promise<Comment> {
  const res = await fetch(`/api/posts/${postId}/comments`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text }),
  });
  if (!res.ok) throw new Error("Failed to add comment");
  return res.json();
}
 
async function deleteComment(commentId: string): Promise<void> {
  const res = await fetch(`/api/comments/${commentId}`, { method: "DELETE" });
  if (!res.ok) throw new Error("Failed to delete comment");
}
 
export function CommentList({
  postId,
  initialComments,
  currentUser,
}: {
  postId: string;
  initialComments: Comment[];
  currentUser: string;
}) {
  const [comments, setComments] = useState(initialComments);
  const [isPending, startTransition] = useTransition();
 
  const [optimisticComments, updateOptimisticComments] = useOptimistic(
    comments,
    (current, action: { type: "add"; comment: Comment } | { type: "delete"; id: string }) => {
      if (action.type === "add") {
        return [...current, action.comment];
      }
      return current.filter((c) => c.id !== action.id);
    }
  );
 
  function handleAdd(text: string) {
    const tempId = `temp-${Date.now()}`;
    const optimisticComment: Comment = {
      id: tempId,
      text,
      author: currentUser,
      pending: true,
    };
 
    startTransition(async () => {
      updateOptimisticComments({ type: "add", comment: optimisticComment });
 
      try {
        const real = await addComment(postId, text);
        setComments((prev) => [...prev, real]);
      } catch {
        // optimistic comment disappears automatically on revert
        alert("Failed to post comment. Please try again.");
      }
    });
  }
 
  function handleDelete(id: string) {
    startTransition(async () => {
      updateOptimisticComments({ type: "delete", id });
 
      try {
        await deleteComment(id);
        setComments((prev) => prev.filter((c) => c.id !== id));
      } catch {
        alert("Failed to delete comment.");
      }
    });
  }
 
  return (
    <div>
      {optimisticComments.map((comment) => (
        <div key={comment.id} style={{ opacity: comment.pending ? 0.6 : 1 }}>
          <strong>{comment.author}</strong>: {comment.text}
          {comment.author === currentUser && !comment.pending && (
            <button onClick={() => handleDelete(comment.id)}>Delete</button>
          )}
          {comment.pending && <span> (saving...)</span>}
        </div>
      ))}
      <CommentForm onSubmit={handleAdd} />
    </div>
  );
}

Notice the pending flag on optimistic items. Show them at reduced opacity so users know the save is in flight. Don't disable interaction while pending — that defeats the purpose.

If the trigger is a <form> rather than a button, pair useOptimistic with React 19's useActionState, which threads the pending flag and the action result for you instead of you hand-rolling useTransition plus a state variable.

Variation 2: With TanStack Query

If you're already using TanStack Query (React Query) for data fetching, it has first-class optimistic update support via onMutate, onError, and onSettled. This approach works without useOptimistic and integrates cleanly into your existing query/mutation setup.

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 
interface Post {
  id: string;
  likes: number;
  liked: boolean;
}
 
const fetchPost = (postId: string): Promise<Post> =>
  fetch(`/api/posts/${postId}`).then((r) => r.json());
 
export function useLikeMutation(postId: string) {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: () =>
      fetch(`/api/posts/${postId}/like`, { method: "POST" }).then((r) => r.json()),
 
    onMutate: async () => {
      // Cancel any in-flight refetches that would overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: ["post", postId] });
 
      // Snapshot current value for rollback
      const previous = queryClient.getQueryData<Post>(["post", postId]);
 
      // Apply optimistic update to the cache
      queryClient.setQueryData<Post>(["post", postId], (old) => {
        if (!old) return old;
        return {
          ...old,
          likes: old.liked ? old.likes - 1 : old.likes + 1,
          liked: !old.liked,
        };
      });
 
      // Return context with snapshot for onError
      return { previous };
    },
 
    onError: (_err, _vars, context) => {
      // Roll back to the snapshot on failure
      if (context?.previous) {
        queryClient.setQueryData(["post", postId], context.previous);
      }
    },
 
    onSettled: () => {
      // Always refetch after error or success to sync with server
      queryClient.invalidateQueries({ queryKey: ["post", postId] });
    },
  });
}
 
// Usage in component
export function LikeButton({ postId }: { postId: string }) {
  const { data: post } = useQuery({ queryKey: ["post", postId], queryFn: () => fetchPost(postId) });
  const { mutate: toggleLike } = useLikeMutation(postId);
 
  if (!post) return null;
 
  return (
    <button onClick={() => toggleLike()} className={post.liked ? "liked" : ""}>
      ❤️ {post.likes}
    </button>
  );
}

The onMutate / onError / onSettled lifecycle is TanStack Query's rollback mechanism. onMutate runs before the request fires — you cancel competing queries, snapshot state, and apply the optimistic update. onError reverts. onSettled invalidates to get fresh data. This pattern scales well when you have complex cache relationships across multiple query keys.

Variation 3: Manual Approach (No Library)

Sometimes you're pinned to React 18 or earlier, or you want explicit control over rollback timing rather than "whenever the action ends". The manual approach is straightforward: keep two pieces of state and swap them on error.

import { useState, useCallback } from "react";
 
export function useOptimisticState<T>(initialValue: T) {
  const [committed, setCommitted] = useState(initialValue);
  const [optimistic, setOptimistic] = useState(initialValue);
 
  const runOptimistic = useCallback(
    async (
      optimisticValue: T,
      asyncFn: () => Promise<T>
    ) => {
      setOptimistic(optimisticValue); // Update UI immediately
 
      try {
        const result = await asyncFn();
        setCommitted(result);
        setOptimistic(result); // Sync to server's canonical value
      } catch (error) {
        setOptimistic(committed); // Explicit rollback on failure
        throw error; // Let the caller handle the error
      }
    },
    [committed]
  );
 
  return [optimistic, runOptimistic] as const;
}
 
// Usage
function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, runOptimistic] = useOptimisticState(initialLikes);
 
  async function handleLike() {
    try {
      await runOptimistic(
        likes + 1, // Optimistic value
        async () => {
          const res = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
          const data = await res.json();
          return data.likes; // Real value from server
        }
      );
    } catch {
      alert("Couldn't like post. Try again.");
    }
  }
 
  return <button onClick={handleLike}>❤️ {likes}</button>;
}

This is more verbose but explicit — you can read exactly what's happening without knowing how useOptimistic behaves. It also gives you something the hook won't: an optimistic value that outlives a single action. The cost is that every rollback path is now yours to get right, including the case where two of these race.

When NOT to Use This

⚠️

Don't use optimistic updates for irreversible or high-stakes actions.

If the user deletes their account, makes a payment, sends a wire transfer, submits a legal form, or triggers any action where a failed rollback would cause real harm — do not optimistically update. Make them wait for server confirmation. A round-trip of latency is not worth the risk of showing a user their payment "succeeded" when it didn't, or that their message "sent" when it failed in compliance systems.

The same applies to actions that depend on server-computed values you can't reliably predict client-side — like generating an ID, computing a price after discount codes, or running server-side business logic. If you can't accurately predict what the server will return, your optimistic state will visibly "snap" when the real value comes back. That's worse than waiting.

Red flags that disqualify optimistic updates:

  • Financial transactions (payments, refunds, transfers)
  • Irreversible deletes (user accounts, billing records)
  • Actions with side effects you can't undo (sending emails, triggering webhooks)
  • When the server result depends on information you don't have client-side
  • Multi-step workflows where step N depends on the confirmed result of step N-1

Use optimistic updates for the low-stakes, high-frequency interactions where correctness is recoverable: likes, bookmarks, drag-and-drop reordering, toggling settings, adding to cart, marking tasks done. These are the interactions that happen dozens of times per session and where the optimistic value is almost always correct. Get those right and your app feels snappy without any infrastructure changes.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
Two pointers, one pass, O(n) — learn how the variable-size sliding window grows and shrinks to solve substring and subarray problems efficiently.
AdminAugust 2, 20264 min read