skills.vishalvoid
all skills
Patterns

React Optimistic Updates

A TanStack Query v5 pattern that updates the UI immediately on user action, rolls back automatically on error, and revalidates in the background. Eliminates the lag between user intent and visual response in every CRUD interface.

ReactTanStack QueryTypeScriptUX

Install

$curl -fsSL https://skills.vishalvoid.com/install react-optimistic-updates | bash

The problem

Traditional CRUD flows create a loop: user acts → spinner appears → server responds → UI updates. Every step after the first is invisible latency. On a fast connection this is annoying; on a slow one it destroys the experience. Optimistic updates invert this: apply the change immediately, sync with the server in the background, and only surface errors if something actually goes wrong.

Core hook — useMutation with rollback

The three hooks that make optimistic updates safe: `onMutate` applies the change and saves a snapshot, `onError` restores from the snapshot if the server rejects it, `onSettled` always triggers a fresh revalidation so the cache stays accurate.

hooks/use-update-item.ts
// hooks/use-update-item.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateItem } from '@/lib/api'
import type { Item } from '@/lib/types'

export function useUpdateItem() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: updateItem,

    onMutate: async (updated: Partial<Item> & { id: string }) => {
      // Cancel any in-flight refetches for this query
      await queryClient.cancelQueries({ queryKey: ['items'] })

      // Snapshot current state for rollback
      const previous = queryClient.getQueryData<Item[]>(['items'])

      // Optimistically apply the update
      queryClient.setQueryData<Item[]>(['items'], (old = []) =>
        old.map((item) =>
          item.id === updated.id ? { ...item, ...updated } : item
        )
      )

      return { previous }
    },

    onError: (_err, _vars, context) => {
      // Rollback to the snapshot on failure
      if (context?.previous) {
        queryClient.setQueryData(['items'], context.previous)
      }
    },

    onSettled: () => {
      // Always revalidate after mutation (success or failure)
      queryClient.invalidateQueries({ queryKey: ['items'] })
    },
  })
}

Applying it in a component

The component has zero awareness of loading state — it just calls `mutate` and the UI responds instantly. Error handling lives in the mutation hook, not scattered across components.

components/item-row.tsx
// components/item-row.tsx
'use client'

import { useUpdateItem } from '@/hooks/use-update-item'
import type { Item } from '@/lib/types'

export function ItemRow({ item }: { item: Item }) {
  const { mutate } = useUpdateItem()

  return (
    <div className="flex items-center gap-3 py-2">
      <input
        type="checkbox"
        checked={item.completed}
        onChange={(e) =>
          mutate({ id: item.id, completed: e.target.checked })
        }
      />
      <span
        className={item.completed ? 'line-through text-muted' : ''}
      >
        {item.title}
      </span>
    </div>
  )
}

Optimistic deletes

Deletes follow the same pattern but filter out the item rather than merging properties. The item disappears from the list instantly; if the server fails, it reappears.

hooks/use-delete-item.ts
export function useDeleteItem() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (id: string) => deleteItem(id),

    onMutate: async (id: string) => {
      await queryClient.cancelQueries({ queryKey: ['items'] })
      const previous = queryClient.getQueryData<Item[]>(['items'])

      queryClient.setQueryData<Item[]>(['items'], (old = []) =>
        old.filter((item) => item.id !== id)
      )

      return { previous }
    },

    onError: (_err, _id, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['items'], context.previous)
      }
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['items'] })
    },
  })
}

Optimistic creates with temporary IDs

For creates, generate a temporary client-side ID so the item can be placed in the list immediately. When the server responds with the real ID, `onSettled` revalidation replaces it automatically.

hooks/use-create-item.ts
export function useCreateItem() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: createItem,

    onMutate: async (newItem: NewItem) => {
      await queryClient.cancelQueries({ queryKey: ['items'] })
      const previous = queryClient.getQueryData<Item[]>(['items'])

      // Use a temp ID — server will assign the real one
      const optimistic: Item = {
        ...newItem,
        id: `temp-${crypto.randomUUID()}`,
        createdAt: new Date().toISOString(),
      }

      queryClient.setQueryData<Item[]>(['items'], (old = []) => [
        optimistic,
        ...old,
      ])

      return { previous }
    },

    onError: (_err, _vars, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['items'], context.previous)
      }
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['items'] })
    },
  })
}

When to use — and when not to

Use optimistic updates when: toggling boolean states (complete, archived, starred), inline text edits where you know the exact new value, drag-to-reorder, list item deletion. Avoid when: the server generates data you can't predict (computed fields, database triggers, external API calls during mutation), or when concurrent edits from other users make the snapshot unreliable.

Common pitfalls

1. Forgetting `cancelQueries` — without it, an in-flight refetch can overwrite your optimistic state mid-mutation. 2. Not returning `previous` from `onMutate` — TanStack Query passes `context` to `onError` from the return value of `onMutate`; if you don't return it, rollback is impossible. 3. Mutating the cache directly — always use `setQueryData` with an immutable update, never push/splice into the array.

Related skills

back to all skills