DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Optimistic UI Updates: Make Your App Feel Instant

Optimistic UI Updates: Make Your App Feel Instant

Waiting for a server response before updating the UI makes apps feel slow. Optimistic updates apply the change immediately, then reconcile with the server response — reverting only if the request fails.

The Pattern

User action
    ↓
Update UI immediately (optimistic)
    ↓
Send request to server
    ↓
Success: confirm (no visible change needed)
Failure: revert UI + show error
Enter fullscreen mode Exit fullscreen mode

With Tanstack Query

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

function TodoItem({ todo }: { todo: Todo }) {
  const queryClient = useQueryClient();

  const toggleMutation = useMutation({
    mutationFn: (id: string) =>
      fetch(`/api/todos/${id}/toggle`, { method: 'POST' }).then(r => r.json()),

    onMutate: async (id) => {
      // Cancel in-flight queries to avoid overwriting optimistic update
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Snapshot previous state for rollback
      const previous = queryClient.getQueryData<Todo[]>(['todos']);

      // Apply optimistic update
      queryClient.setQueryData<Todo[]>(['todos'], old =>
        old?.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
      );

      return { previous };
    },

    onError: (err, id, context) => {
      // Revert on error
      queryClient.setQueryData(['todos'], context?.previous);
    },

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

  return (
    <div
      onClick={() => toggleMutation.mutate(todo.id)}
      className={todo.completed ? 'line-through opacity-50' : ''}
    >
      {todo.title}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With React useState (Simple Cases)

function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [liked, setLiked] = useState(false);

  async function handleLike() {
    // Update immediately
    setLiked(true);
    setLikes(l => l + 1);

    try {
      await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
    } catch {
      // Revert on failure
      setLiked(false);
      setLikes(l => l - 1);
    }
  }

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

Next.js Server Action Optimism

'use client';
import { useOptimistic } from 'react';

function MessageList({ messages, sendMessage }: Props) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, newMessage: string) => [
      ...state,
      { id: 'temp', text: newMessage, pending: true }
    ]
  );

  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string;
    addOptimistic(text);  // Show immediately
    await sendMessage(formData); // Then persist
  }

  return (
    <>
      {optimisticMessages.map(m => (
        <div key={m.id} className={m.pending ? 'opacity-50' : ''}>
          {m.text}
        </div>
      ))}
      <form action={handleSubmit}>
        <input name='text' />
        <button type='submit'>Send</button>
      </form>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Optimistic Updates

  • Financial transactions (always confirm server-side first)
  • Irreversible actions (deletion — show confirmation instead)
  • Actions where server response contains new data needed immediately

Optimistic UI patterns are implemented throughout the dashboard in the AI SaaS Starter Kit. $99 at whoffagents.com.

Top comments (0)