Dhirendra.
Back to Blog
ReactUXPerformanceState Management

Optimistic UI Patterns That Actually Work

Real-world patterns for building fast, predictable UIs with Zustand and React Query — and how to handle rollbacks gracefully when things go wrong.

October 5, 20244 min read622 words

What Optimistic UI Actually Means

Optimistic UI means updating the interface before the server confirms the change. You assume the operation will succeed — hence "optimistic" — and show the result immediately. If the server rejects it, you roll back.

Done right, this makes apps feel instant. Done wrong, it creates inconsistencies that erode user trust. Most tutorials show you the happy path. This post covers the full picture: updates, rollbacks, loading states, and the edge cases that bite you in production.

The Basic Pattern with React Query

React Query's useMutation has built-in support for optimistic updates via onMutate, onError, and onSettled:

const queryClient = useQueryClient();

const likeMutation = useMutation({
  mutationFn: (postId: string) => api.likePost(postId),

  onMutate: async (postId) => {
    // 1. Cancel any in-flight refetches to prevent overwriting
    await queryClient.cancelQueries({ queryKey: ["posts"] });

    // 2. Snapshot the current value for rollback
    const previous = queryClient.getQueryData(["posts"]);

    // 3. Optimistically update the cache
    queryClient.setQueryData(["posts"], (old: Post[]) =>
      old.map((p) => p.id === postId ? { ...p, likes: p.likes + 1 } : p)
    );

    // 4. Return context for rollback
    return { previous };
  },

  onError: (err, postId, context) => {
    // Rollback to the snapshot
    queryClient.setQueryData(["posts"], context?.previous);
  },

  onSettled: () => {
    // Always refetch to sync with server truth
    queryClient.invalidateQueries({ queryKey: ["posts"] });
  },
});

The three-step pattern — snapshot, update, return context — is the foundation. Never skip the snapshot.

Handling Concurrent Mutations

The pattern above breaks when a user fires multiple mutations quickly. Like button spam is the classic case. The fix is to use a queue and only roll back the specific mutation that failed, not all of them:

// Track pending optimistic updates separately
const pendingLikes = useRef(new Set<string>());

onMutate: async (postId) => {
  pendingLikes.current.add(postId);
  // ... update cache by counting pendingLikes
},

onSettled: (data, error, postId) => {
  pendingLikes.current.delete(postId);
  queryClient.invalidateQueries({ queryKey: ["posts"] });
},

The Zustand Approach

For local state that doesn't need server sync, Zustand gives you more control. Here's a todo list with optimistic delete and undo:

interface TodoStore {
  todos: Todo[];
  deleteTodo: (id: string) => void;
  undoDelete: (todo: Todo) => void;
}

const useTodoStore = create<TodoStore>((set, get) => ({
  todos: [],

  deleteTodo: (id) => {
    const todo = get().todos.find((t) => t.id === id);
    // Remove immediately from UI
    set((s) => ({ todos: s.todos.filter((t) => t.id !== id) }));

    // Fire the API
    api.deleteTodo(id).catch(() => {
      // Restore on failure
      if (todo) set((s) => ({ todos: [...s.todos, todo] }));
      toast.error("Couldn't delete. Restored.");
    });
  },
}));

Showing the Right Loading States

Optimistic UIs shouldn't be invisible. Users need subtle feedback that their action was received, even before server confirmation:

function LikeButton({ post }: { post: Post }) {
  const { mutate, isPending } = useLikeMutation();

  return (
    <button
      onClick={() => mutate(post.id)}
      style={{ opacity: isPending ? 0.7 : 1 }}
      aria-label={`Like post. ${post.likes} likes.`}
    >
      <Heart
        fill={post.isLiked ? "currentColor" : "none"}
        // Scale pulse on pending
        className={isPending ? "animate-pulse" : ""}
      />
      {post.likes}
    </button>
  );
}

Small opacity changes and pulse animations signal "working on it" without blocking the user.

The Golden Rules

Three rules I've settled on after shipping many optimistic UIs:

1. Always snapshot before mutating. You cannot roll back what you didn't save.

2. Invalidate on settle, not on success. onSettled fires whether the mutation succeeded or failed. Invalidating there ensures you always eventually sync with the server.

3. Make rollbacks visible. A silent rollback is worse than an error message. Use a toast or inline error so users know their action didn't land and can try again.

Optimistic UI is a UX investment. Get the error paths right and it builds trust. Get them wrong and it destroys it.

// keep reading

Related Articles