DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Optimistic Updates in React: Making Your UI Feel Instant

Optimistic Updates in React: Making Your UI Feel Instant

Waiting for a server response before updating the UI feels sluggish.
Optimistic updates show the result immediately, then reconcile with the server.

The Pattern

User clicks 'Like'
  1. Immediately update UI (show filled heart)
  2. Send API request in background
  3a. Success: server confirms — do nothing (already correct)
  3b. Failure: rollback UI to original state
Enter fullscreen mode Exit fullscreen mode

With React Query

function LikeButton({ postId, liked, likeCount }: LikeButtonProps) {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: () => toggleLike(postId),

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

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

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

      return { previous }
    },

    onError: (err, _variables, context) => {
      // Rollback on failure
      if (context?.previous) {
        queryClient.setQueryData(['post', postId], context.previous)
      }
    },

    onSettled: () => {
      // Always refetch to ensure sync
      queryClient.invalidateQueries({ queryKey: ['post', postId] })
    },
  })

  return (
    <button onClick={() => mutation.mutate()} className={liked ? 'text-red-500' : 'text-gray-400'}>
      <HeartIcon /> {likeCount}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

With useOptimistic (React 19)

'use client'

import { useOptimistic, useTransition } from 'react'

function TodoList({ todos }: { todos: Todo[] }) {
  const [isPending, startTransition] = useTransition()
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: Todo) => [...state, newTodo]
  )

  const addTodo = async (text: string) => {
    const tempTodo = { id: crypto.randomUUID(), text, done: false }

    startTransition(async () => {
      addOptimisticTodo(tempTodo)  // shows immediately
      await createTodo(text)        // server action
      // After server action completes, React reconciles real data
    })
  }

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id} className={isPending ? 'opacity-70' : ''}>
          {todo.text}
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

With Zustand

const useCartStore = create((set) => ({
  items: [],

  addItem: async (product: Product) => {
    // Optimistic update
    set(state => ({ items: [...state.items, { ...product, pending: true }] }))

    try {
      const saved = await api.addToCart(product.id)
      // Replace pending item with server response
      set(state => ({
        items: state.items.map(item =>
          item.id === product.id ? { ...saved, pending: false } : item
        ),
      }))
    } catch {
      // Rollback
      set(state => ({
        items: state.items.filter(item => item.id !== product.id),
      }))
      toast.error('Failed to add to cart')
    }
  },
}))
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Optimistic Updates

  • Payment actions (show pending state, wait for confirmation)
  • Destructive actions that are hard to reverse
  • When server validation is complex and likely to reject
  • When showing wrong data briefly could cause real harm

For these: show a loading spinner and wait.

Loading States That Work

// Skeleton screens > spinners for layout-level loading
// Inline indicators for action feedback

function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  const [state, setState] = useState<'idle' | 'saving' | 'saved'>('idle')

  const handleClick = async () => {
    setState('saving')
    await onSave()
    setState('saved')
    setTimeout(() => setState('idle'), 2000)
  }

  return (
    <button onClick={handleClick} disabled={state === 'saving'}>
      {state === 'idle' && 'Save'}
      {state === 'saving' && 'Saving...'}
      {state === 'saved' && 'Saved!'}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

The AI SaaS Starter Kit ships with React Query configured for optimistic updates and the full SaaS UI patterns pre-built. $99 one-time.

Top comments (0)