DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Optimistic Updates in React: UX That Feels Instant

Why Your UI Feels Slow

User clicks 'Like'. Nothing happens for 300ms. Button finally changes state.

That 300ms delay is your network round-trip. The server almost certainly succeeded. But the UI waited anyway.

Optimistic updates flip this: assume success, update immediately, rollback if wrong.

The Pattern

// Before: wait for server
async function likePost(postId: string) {
  const response = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  const updated = await response.json();
  setPosts(posts.map(p => p.id === postId ? updated : p));
}

// After: update immediately, sync in background  
async function likePost(postId: string) {
  // 1. Update UI immediately
  setPosts(posts.map(p => 
    p.id === postId ? { ...p, likes: p.likes + 1, liked: true } : p
  ));

  try {
    // 2. Sync with server
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  } catch (error) {
    // 3. Rollback on failure
    setPosts(posts.map(p => 
      p.id === postId ? { ...p, likes: p.likes - 1, liked: false } : p
    ));
    toast.error('Failed to like post');
  }
}
Enter fullscreen mode Exit fullscreen mode

With React Query

React Query has built-in optimistic update support:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function useLikePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) =>
      fetch(`/api/posts/${postId}/like`, { method: 'POST' }).then(r => r.json()),

    onMutate: async (postId) => {
      // Cancel any outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['posts'] });

      // Snapshot the previous value
      const previousPosts = queryClient.getQueryData(['posts']);

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

      // Return context with snapshot for rollback
      return { previousPosts };
    },

    onError: (error, postId, context) => {
      // Rollback to snapshot
      queryClient.setQueryData(['posts'], context?.previousPosts);
      toast.error('Failed to like post');
    },

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

// Usage
function PostCard({ post }: { post: Post }) {
  const { mutate: likePost } = useLikePost();

  return (
    <button onClick={() => likePost(post.id)}>
      {post.liked ? '❤️' : '🤍'} {post.likes}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

With SWR

import useSWR, { mutate } from 'swr';

function usePost(postId: string) {
  return useSWR(`/api/posts/${postId}`, fetcher);
}

async function likePost(postId: string) {
  // Optimistic update with SWR
  await mutate(
    `/api/posts/${postId}`,
    async (currentPost: Post) => {
      // This runs after the optimistic update
      const updated = await fetch(`/api/posts/${postId}/like`, { 
        method: 'POST' 
      }).then(r => r.json());
      return updated;
    },
    {
      optimisticData: (currentPost: Post) => ({
        ...currentPost,
        likes: currentPost.likes + 1,
        liked: true,
      }),
      rollbackOnError: true,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

More Complex Example: Todo List

function useTodos() {
  const queryClient = useQueryClient();
  const { data: todos } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  const addTodo = useMutation({
    mutationFn: createTodo,

    onMutate: async (newTodoText: string) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData(['todos']);

      // Add with temp ID
      const optimisticTodo = {
        id: `temp-${Date.now()}`,
        text: newTodoText,
        completed: false,
        _isOptimistic: true, // Visual indicator
      };

      queryClient.setQueryData(['todos'], (old: Todo[]) => [
        ...old,
        optimisticTodo,
      ]);

      return { previousTodos };
    },

    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },

    onSuccess: (newTodo) => {
      // Replace optimistic with real todo
      queryClient.setQueryData(['todos'], (old: Todo[]) =>
        old.map(t => t._isOptimistic ? newTodo : t)
      );
    },
  });

  return { todos, addTodo };
}
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Optimistic Updates

  • Financial transactions: Don't optimistically show a payment succeeded
  • Destructive operations: Deleting something permanently needs confirmation
  • High failure rate operations: If 20% of requests fail, optimistic updates cause confusion
  • Real-time collaborative data: Risk of showing stale state to users

The Rules

  1. Always snapshot before mutating
  2. Always rollback on error with user-visible feedback
  3. Always eventually sync with server truth (onSettled invalidation)
  4. Show a loading indicator for operations > 500ms even with optimistic updates

Most CRUD operations in a typical SaaS succeed > 99% of the time. For those, the 300ms you save matters more than the edge case rollback.


Building a React SaaS with great UX patterns? Whoff Agents AI SaaS Starter Kit includes React Query setup with optimistic update patterns pre-wired.

Top comments (0)