Skip to content

Optimistic UI Updates: Instant Interfaces with Rollback

Implement optimistic UI updates that feel instant by showing expected state immediately, reconciling with the server and rolling back when a mutation fails.

4 min read
A timeline showing optimistic state applied instantly on the client while the server processes the mutation in the background

Why Optimistic Updates

Every network request introduces latency. When a user clicks "like," waiting 200ms for the server to respond before updating the UI feels sluggish. Optimistic updates apply the expected result immediately, making the interface feel instant. If the server rejects the mutation, the UI rolls back to the previous state.

The Basic Pattern

The core flow: save current state, apply optimistic change, send mutation, confirm or rollback.

tstypescript
// ❌ Pessimistic — user waits for server round trip
async function handleLike(postId: string): Promise<void> {
  setLoading(true);
  const result = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
  if (result.ok) {
    setLiked(true);
    setLikeCount((c) => c + 1);
  }
  setLoading(false);
}
 
// ✅ Optimistic — instant feedback with rollback
async function handleLike(postId: string): Promise<void> {
  // 1. Save previous state for rollback
  const previousLiked = liked;
  const previousCount = likeCount;
 
  // 2. Apply optimistic update immediately
  setLiked(true);
  setLikeCount((c) => c + 1);
 
  try {
    // 3. Send mutation to server
    const result = await fetch(`/api/posts/${postId}/like`, {
      method: "POST",
    });
 
    if (!result.ok) throw new Error("Like failed");
 
    // 4. Optionally reconcile with server data
    const data = await result.json();
    setLikeCount(data.likeCount); // Use server's canonical count
  } catch {
    // 5. Rollback on failure
    setLiked(previousLiked);
    setLikeCount(previousCount);
    toast.error("Failed to like post. Please try again.");
  }
}

Generic Optimistic Hook

Extract the pattern into a reusable hook that handles any optimistic mutation with automatic rollback.

tstypescript
import { useState, useCallback, useRef } from "react";
 
interface OptimisticOptions<T> {
  onError?: (error: Error, previousState: T) => void;
  onSuccess?: (result: unknown, currentState: T) => void;
}
 
function useOptimistic<T>(
  initialState: T,
  options?: OptimisticOptions<T>
): {
  state: T;
  isPending: boolean;
  update: (optimisticState: T, mutation: () => Promise<unknown>) => void;
} {
  const [state, setState] = useState(initialState);
  const [isPending, setIsPending] = useState(false);
  const rollbackRef = useRef<T>(initialState);
 
  const update = useCallback(
    async (optimisticState: T, mutation: () => Promise<unknown>) => {
      // Store rollback point
      rollbackRef.current = state;
      setState(optimisticState);
      setIsPending(true);
 
      try {
        const result = await mutation();
        options?.onSuccess?.(result, optimisticState);
      } catch (error) {
        // Rollback to previous state
        setState(rollbackRef.current);
        options?.onError?.(error as Error, rollbackRef.current);
      } finally {
        setIsPending(false);
      }
    },
    [state, options]
  );
 
  return { state, isPending, update };
}

Optimistic Lists: Adding and Removing Items

List operations—adding, removing, reordering—benefit most from optimistic updates because the latency is most noticeable.

tsxtsx
interface Todo {
  id: string;
  text: string;
  completed: boolean;
}
 
function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
 
  async function addTodo(text: string): Promise<void> {
    // Generate a temporary ID for the optimistic item
    const tempId = `temp-${crypto.randomUUID()}`;
    const optimisticTodo: Todo = {
      id: tempId,
      text,
      completed: false,
    };
 
    // Add immediately
    setTodos((prev) => [...prev, optimisticTodo]);
 
    try {
      const response = await fetch("/api/todos", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text }),
      });
      const created = await response.json();
 
      // Replace temp item with server-created item (gets real ID)
      setTodos((prev) =>
        prev.map((t) => (t.id === tempId ? created : t))
      );
    } catch {
      // Remove the optimistic item
      setTodos((prev) => prev.filter((t) => t.id !== tempId));
      toast.error("Failed to add todo");
    }
  }
 
  async function removeTodo(id: string): Promise<void> {
    const previousTodos = [...todos];
 
    // Remove immediately
    setTodos((prev) => prev.filter((t) => t.id !== id));
 
    try {
      await fetch(`/api/todos/${id}`, { method: "DELETE" });
    } catch {
      // Restore the item
      setTodos(previousTodos);
      toast.error("Failed to remove todo");
    }
  }
 
  return (
    <ul>
      {todos.map((todo) => (
        <li
          key={todo.id}
          style={{
            opacity: todo.id.startsWith("temp-") ? 0.6 : 1,
          }}
        >
          {todo.text}
          <button onClick={() => removeTodo(todo.id)}>×</button>
        </li>
      ))}
    </ul>
  );
}

React 19 useOptimistic

React 19 includes a built-in useOptimistic hook that simplifies the pattern, especially with Server Actions.

tsxtsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/actions/likes";
 
interface Post {
  id: string;
  title: string;
  liked: boolean;
  likeCount: number;
}
 
function PostCard({ post }: { post: Post }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticPost, setOptimisticPost] = useOptimistic(
    post,
    (currentPost: Post, newLiked: boolean) => ({
      ...currentPost,
      liked: newLiked,
      likeCount: currentPost.likeCount + (newLiked ? 1 : -1),
    })
  );
 
  function handleToggleLike() {
    startTransition(async () => {
      setOptimisticPost(!optimisticPost.liked);
      await toggleLike(post.id);
    });
  }
 
  return (
    <article>
      <h2>{optimisticPost.title}</h2>
      <button onClick={handleToggleLike} disabled={isPending}>
        {optimisticPost.liked ? "❤️" : "🤍"} {optimisticPost.likeCount}
      </button>
    </article>
  );
}

Handling Concurrent Optimistic Updates

When users trigger multiple mutations rapidly—toggling likes, reordering items—you need to handle concurrent optimistic states without corruption.

tstypescript
class OptimisticQueue<T> {
  private pendingUpdates: Map<string, {
    previous: T;
    optimistic: T;
    mutation: Promise<unknown>;
  }> = new Map();
 
  async enqueue(
    key: string,
    currentState: T,
    optimisticState: T,
    mutation: () => Promise<T>,
    onStateChange: (state: T) => void
  ): Promise<void> {
    // Cancel conflicting pending update for same key
    if (this.pendingUpdates.has(key)) {
      // Use the ORIGINAL previous state, not the optimistic one
      const existing = this.pendingUpdates.get(key)!;
      this.pendingUpdates.delete(key);
      currentState = existing.previous;
    }
 
    onStateChange(optimisticState);
 
    const mutationPromise = mutation();
    this.pendingUpdates.set(key, {
      previous: currentState,
      optimistic: optimisticState,
      mutation: mutationPromise,
    });
 
    try {
      const serverState = await mutationPromise;
      if (this.pendingUpdates.has(key)) {
        onStateChange(serverState);
        this.pendingUpdates.delete(key);
      }
    } catch {
      if (this.pendingUpdates.has(key)) {
        onStateChange(this.pendingUpdates.get(key)!.previous);
        this.pendingUpdates.delete(key);
      }
    }
  }
}

Visual Feedback During Pending State

Users should know when data is optimistic versus confirmed. Subtle visual cues maintain trust without blocking interaction.

tsxtsx
function CommentItem({
  comment,
  isPending,
}: {
  comment: Comment;
  isPending: boolean;
}) {
  return (
    <div
      className={`comment ${isPending ? "comment--pending" : ""}`}
      aria-busy={isPending}
    >
      <p>{comment.text}</p>
      <span className="comment__author">{comment.authorName}</span>
      {isPending && (
        <span className="comment__status" aria-label="Saving comment">
          Saving...
        </span>
      )}
    </div>
  );
}
csscss
.comment--pending {
  opacity: 0.7;
  pointer-events: none;
  position: relative;
}
 
.comment--pending::after {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    -45deg,
    transparent,
    transparent 4px,
    rgba(0, 0, 0, 0.03) 4px,
    rgba(0, 0, 0, 0.03) 8px
  );
}

Key Takeaways

Optimistic updates make interfaces feel instant by applying expected state immediately and reconciling with the server afterward. Always save the previous state before applying optimistic changes so you can roll back on failure. Use temporary IDs for newly created items, then replace them with server-generated IDs on success.

Handle concurrent mutations carefully—when users act faster than the network, maintain the correct rollback state. Show subtle visual feedback (reduced opacity, "saving..." indicators) so users know data is pending without blocking their interaction. React 19's useOptimistic hook simplifies the pattern for Server Actions. The key insight: users perceive your app as fast when the UI responds to their actions immediately, even if the server takes 200ms to confirm.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX