Front-end Engineering Lab

Optimistic UI Patterns

Update UI instantly before server confirms for better UX

Optimistic UI updates the interface immediately, assuming the operation will succeed. If it fails, roll back. This creates instant-feeling apps.

Basic Pattern

export function OptimisticTodo() {
  const [todos, setTodos] = useState<Todo[]>([]);

  const addTodo = async (text: string) => {
    const tempId = crypto.randomUUID();
    const newTodo = { id: tempId, text, completed: false };

    // Optimistic update
    setTodos(prev => [...prev, newTodo]);

    try {
      // API call
      const response = await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ text }),
      });
      
      const saved = await response.json();

      // Replace temp with real
      setTodos(prev =>
        prev.map(t => t.id === tempId ? saved : t)
      );
    } catch (error) {
      // Rollback on error
      setTodos(prev => prev.filter(t => t.id !== tempId));
      showError('Failed to add todo');
    }
  };

  return <div>{/* UI */}</div>;
}

React Query Pattern

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

export function useTodoMutations() {
  const queryClient = useQueryClient();

  const addTodo = useMutation({
    mutationFn: (text: string) =>
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ text }),
      }).then(r => r.json()),
    
    onMutate: async (text) => {
      // Cancel ongoing queries
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Snapshot previous value
      const previous = queryClient.getQueryData(['todos']);

      // Optimistic update
      queryClient.setQueryData(['todos'], (old: Todo[]) => [
        ...old,
        { id: 'temp', text, completed: false },
      ]);

      // Return context with snapshot
      return { previous };
    },
    
    onError: (_err, _variables, context) => {
      // Rollback on error
      queryClient.setQueryData(['todos'], context?.previous);
    },
    
    onSettled: () => {
      // Refetch after success or error
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return { addTodo };
}

Like Button Pattern

export function LikeButton({ postId, initialLikes, initialLiked }: Props) {
  const [likes, setLikes] = useState(initialLikes);
  const [liked, setLiked] = useState(initialLiked);
  const [isUpdating, setIsUpdating] = useState(false);

  const toggleLike = async () => {
    if (isUpdating) return;

    const previousLikes = likes;
    const previousLiked = liked;

    // Optimistic update
    setLiked(!liked);
    setLikes(liked ? likes - 1 : likes + 1);
    setIsUpdating(true);

    try {
      await fetch(`/api/posts/${postId}/like`, {
        method: liked ? 'DELETE' : 'POST',
      });
    } catch (error) {
      // Rollback
      setLiked(previousLiked);
      setLikes(previousLikes);
      showError('Failed to update like');
    } finally {
      setIsUpdating(false);
    }
  };

  return (
    <button onClick={toggleLike} disabled={isUpdating}>
      {liked ? '❤️' : '🤍'} {likes}
    </button>
  );
}

Optimistic Delete with Undo

export function TodoWithUndo() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [deletedTodo, setDeletedTodo] = useState<Todo | null>(null);
  const timeoutRef = useRef<NodeJS.Timeout>();

  const deleteTodo = (id: string) => {
    const todo = todos.find(t => t.id === id);
    if (!todo) return;

    // Optimistic remove
    setTodos(prev => prev.filter(t => t.id !== id));
    setDeletedTodo(todo);

    // Show undo toast
    showUndoToast();

    // Delete after 5 seconds
    timeoutRef.current = setTimeout(async () => {
      try {
        await fetch(`/api/todos/${id}`, { method: 'DELETE' });
        setDeletedTodo(null);
      } catch (error) {
        // Restore on error
        setTodos(prev => [...prev, todo]);
        setDeletedTodo(null);
        showError('Failed to delete');
      }
    }, 5000);
  };

  const undo = () => {
    if (!deletedTodo) return;

    clearTimeout(timeoutRef.current);
    setTodos(prev => [...prev, deletedTodo]);
    setDeletedTodo(null);
  };

  return <div>{/* UI with undo button */}</div>;
}

Conflict Resolution

export function useOptimisticUpdate() {
  const [data, setData] = useState<Data | null>(null);
  const [version, setVersion] = useState(0);

  const update = async (updates: Partial<Data>) => {
    const currentVersion = version;
    const previous = data;

    // Optimistic update
    setData(prev => ({ ...prev, ...updates }));

    try {
      const response = await fetch('/api/data', {
        method: 'PUT',
        headers: { 'If-Match': currentVersion.toString() },
        body: JSON.stringify(updates),
      });

      if (response.status === 409) {
        // Conflict detected
        const serverData = await response.json();
        
        // Show conflict dialog
        const resolution = await showConflictDialog(previous, serverData);
        
        if (resolution === 'server') {
          setData(serverData);
          setVersion(serverData.version);
        } else {
          // Retry with user changes
          await update(updates);
        }
      } else {
        const saved = await response.json();
        setData(saved);
        setVersion(saved.version);
      }
    } catch (error) {
      // Rollback
      setData(previous);
      showError('Update failed');
    }
  };

  return { data, update };
}

Loading States

export function OptimisticWithLoading() {
  const [items, setItems] = useState<Item[]>([]);
  const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());

  const addItem = async (text: string) => {
    const tempId = `temp-${Date.now()}`;

    // Add to pending
    setPendingIds(prev => new Set([...prev, tempId]));

    // Optimistic add
    setItems(prev => [...prev, { id: tempId, text }]);

    try {
      const response = await fetch('/api/items', {
        method: 'POST',
        body: JSON.stringify({ text }),
      });
      
      const saved = await response.json();

      // Replace temp with real
      setItems(prev =>
        prev.map(item => item.id === tempId ? saved : item)
      );
    } catch (error) {
      // Remove on error
      setItems(prev => prev.filter(item => item.id !== tempId));
    } finally {
      // Remove from pending
      setPendingIds(prev => {
        const next = new Set(prev);
        next.delete(tempId);
        return next;
      });
    }
  };

  return (
    <ul>
      {items.map(item => (
        <li
          key={item.id}
          style={{
            opacity: pendingIds.has(item.id) ? 0.5 : 1,
          }}
        >
          {item.text}
          {pendingIds.has(item.id) && <span>⏳</span>}
        </li>
      ))}
    </ul>
  );
}

Best Practices

  1. Show pending state: Opacity, spinner, etc.
  2. Rollback on error: Restore previous state
  3. Show error messages: Tell user what failed
  4. Debounce updates: Don't spam server
  5. Handle conflicts: Detect version mismatches
  6. Undo option: For destructive actions
  7. Cancel ongoing: Prevent race conditions
  8. Test failures: Verify rollback works
  9. Preserve user input: Don't lose their work
  10. Be honest: Show when operation is pending

Optimistic UI makes apps feel instant—implement carefully with proper rollback and error handling!

On this page