Skip to main content
Ganesh Joshi
Back to Blogs

Optimistic UI: update the screen before the server responds

February 20, 20266 min read
Tips
UI interface and state update code on screen

Users expect instant feedback. When someone clicks a like button, they want to see the heart fill immediately—not after a 200ms network round trip. Optimistic UI delivers this by updating the interface before the server confirms the action. If the server fails, the UI reverts. Done well, it makes apps feel fast and responsive.

How optimistic UI works

The pattern has three phases:

Phase What happens
Optimistic update User acts, UI updates immediately
Server request Request sent in background
Reconciliation On success: keep UI state. On error: revert
// Simplified flow
function handleLike() {
  // 1. Update UI immediately
  setLiked(true);
  setLikeCount(count + 1);

  // 2. Send request
  try {
    await likePost(postId);
    // 3a. Success: UI already shows correct state
  } catch (error) {
    // 3b. Error: revert
    setLiked(false);
    setLikeCount(count);
    showError('Failed to like post');
  }
}

When to use optimistic updates

Optimistic UI works best for:

Action type Examples
Toggles Like/unlike, follow/unfollow, bookmark
Additions Add to cart, add comment, create item
Reordering Drag-and-drop lists, priority changes
Updates Edit text inline, change settings

Avoid optimistic updates for:

Action type Why
Destructive actions Delete account, remove shared data
Financial transactions Payments, transfers
Complex validations Server-side business rules
Idempotency concerns Actions that can't be safely retried

React 19 useOptimistic

React 19 introduces useOptimistic for this pattern:

'use client';

import { useOptimistic } from 'react';
import { likePost } from './actions';

interface Post {
  id: string;
  liked: boolean;
  likeCount: number;
}

export function LikeButton({ post }: { post: Post }) {
  const [optimisticPost, addOptimistic] = useOptimistic(
    post,
    (current, newLiked: boolean) => ({
      ...current,
      liked: newLiked,
      likeCount: current.likeCount + (newLiked ? 1 : -1),
    })
  );

  async function handleClick() {
    const newLiked = !optimisticPost.liked;
    addOptimistic(newLiked);

    try {
      await likePost(post.id, newLiked);
    } catch {
      // React automatically reverts on error
    }
  }

  return (
    <button onClick={handleClick}>
      {optimisticPost.liked ? '❤️' : '🤍'} {optimisticPost.likeCount}
    </button>
  );
}

The optimistic state resets when the action completes or errors.

Manual implementation with useState

Without useOptimistic, manage state manually:

'use client';

import { useState } from 'react';

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useState(initialTodos);

  async function addTodo(text: string) {
    const tempId = crypto.randomUUID();
    const optimisticTodo = { id: tempId, text, completed: false, pending: true };

    // Add optimistically
    setTodos((prev) => [...prev, optimisticTodo]);

    try {
      const newTodo = await createTodo(text);
      // Replace temp with real
      setTodos((prev) =>
        prev.map((t) => (t.id === tempId ? { ...newTodo, pending: false } : t))
      );
    } catch {
      // Remove on error
      setTodos((prev) => prev.filter((t) => t.id !== tempId));
      showError('Failed to add todo');
    }
  }

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

The pending flag provides visual feedback that the item is being saved.

TanStack Query optimistic updates

TanStack Query has built-in support through mutation callbacks:

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

export function useLikeMutation(postId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (liked: boolean) => likePost(postId, liked),

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

      // Snapshot previous value
      const previousPost = queryClient.getQueryData(['post', postId]);

      // Optimistically update
      queryClient.setQueryData(['post', postId], (old: Post) => ({
        ...old,
        liked: newLiked,
        likeCount: old.likeCount + (newLiked ? 1 : -1),
      }));

      // Return context for rollback
      return { previousPost };
    },

    onError: (err, newLiked, context) => {
      // Rollback on error
      queryClient.setQueryData(['post', postId], context?.previousPost);
    },

    onSettled: () => {
      // Refetch to ensure server state
      queryClient.invalidateQueries({ queryKey: ['post', postId] });
    },
  });
}

The pattern: snapshot before, update optimistically, rollback on error, refetch on settle.

List operations

Lists require careful handling of temporary IDs:

// Adding to a list
onMutate: async (newItem) => {
  await queryClient.cancelQueries({ queryKey: ['items'] });
  const previousItems = queryClient.getQueryData(['items']);

  const optimisticItem = {
    ...newItem,
    id: `temp-${Date.now()}`,
    pending: true,
  };

  queryClient.setQueryData(['items'], (old: Item[]) => [
    ...old,
    optimisticItem,
  ]);

  return { previousItems, tempId: optimisticItem.id };
},

onSuccess: (createdItem, variables, context) => {
  // Replace temp with real ID
  queryClient.setQueryData(['items'], (old: Item[]) =>
    old.map((item) =>
      item.id === context.tempId ? { ...createdItem, pending: false } : item
    )
  );
},

Reordering with optimistic updates

Drag-and-drop benefits from optimistic updates:

async function handleReorder(items: Item[], dragIndex: number, dropIndex: number) {
  // Calculate new order
  const reordered = arrayMove(items, dragIndex, dropIndex);

  // Update UI immediately
  setItems(reordered);

  try {
    await saveOrder(reordered.map((item) => item.id));
  } catch {
    // Revert to original order
    setItems(items);
    showError('Failed to save order');
  }
}

The UI responds instantly to drags while the server persists changes.

Error handling strategies

Strategy When to use
Silent revert Low-impact actions, user will retry naturally
Toast notification User should know something failed
Inline error Form fields, specific item errors
Modal dialog Critical errors needing acknowledgment
onError: (error, variables, context) => {
  // Revert
  queryClient.setQueryData(['items'], context?.previousItems);

  // Notify user
  toast.error('Failed to update. Your changes were reverted.');
},

Race conditions

Multiple rapid updates can cause race conditions:

// Problem: rapid toggles
// Click 1: liked = true (sends request)
// Click 2: liked = false (sends request)
// Response 1: server has liked = true
// Response 2: server has liked = false
// UI might show wrong state

// Solution: use latest state in request
const latestLiked = useRef(liked);
latestLiked.current = liked;

onSettled: () => {
  // Only refetch if state matches what we sent
  if (latestLiked.current === variables.liked) {
    queryClient.invalidateQueries({ queryKey: ['post', postId] });
  }
},

Or debounce rapid actions.

Visual feedback during optimistic state

Show users that changes are pending:

<li
  key={item.id}
  className={cn(
    'p-4 border rounded',
    item.pending && 'opacity-50 pointer-events-none'
  )}
>
  {item.name}
  {item.pending && <Spinner className="ml-2 w-4 h-4" />}
</li>

Disabled interaction prevents double submissions. Reduced opacity signals in-progress state.

Testing optimistic updates

Test both success and failure paths:

test('reverts on error', async () => {
  // Mock failed request
  server.use(
    rest.post('/api/like', (req, res, ctx) => res(ctx.status(500)))
  );

  render(<LikeButton post={mockPost} />);

  // Click like
  await userEvent.click(screen.getByRole('button'));

  // Should show liked optimistically
  expect(screen.getByText('❤️')).toBeInTheDocument();

  // Wait for error and revert
  await waitFor(() => {
    expect(screen.getByText('🤍')).toBeInTheDocument();
  });
});

Summary

Optimistic UI makes apps feel instant by updating before server confirmation. Use it for high-confidence actions with easy rollback. Implement with useOptimistic in React 19, manual state in earlier versions, or TanStack Query mutations. Always handle errors with graceful rollback and user feedback. Test both success and failure paths to ensure UI consistency.

Frequently Asked Questions

Optimistic UI updates the interface immediately when a user acts, before the server responds. If the server request succeeds, the UI stays as-is. If it fails, the UI reverts to the previous state.

Use optimistic updates for high-confidence actions with low rollback cost: likes, toggles, adding items to lists, reordering. Avoid for destructive actions like deletions or financial transactions.

Update local state immediately on user action, send the request in the background, and revert on error. Use useOptimistic from React 19, or implement with useState and error handling.

TanStack Query's useMutation accepts onMutate, onError, and onSettled callbacks. In onMutate, update the cache optimistically and return a rollback context. In onError, use that context to revert.

Users might see state that doesn't match the server, especially on errors. Poor rollback handling can leave UI inconsistent. Network issues can cause confusing behavior if not handled properly.

Related Posts