typescript 89 lines · 3 tabs

Optimistic UI Updates with Rollback Using React Query useMutation

Shared by codesnips Jul 2026
3 tabs
export interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

const BASE = "/api/todos";

export async function fetchTodos(): Promise<Todo[]> {
  const res = await fetch(BASE);
  if (!res.ok) throw new Error("Failed to load todos");
  return res.json();
}

export async function toggleTodo(id: string): Promise<Todo> {
  const res = await fetch(`${BASE}/${id}/toggle`, { method: "PATCH" });
  if (!res.ok) throw new Error("Failed to toggle todo");
  return res.json();
}
3 files · typescript Explain with highlit

This snippet shows the canonical way to build optimistic UI with automatic rollback using React Query's useMutation lifecycle, split across an API wrapper, a custom mutation hook, and the component that consumes it.

In todosApi, the network layer is kept deliberately thin: toggleTodo issues a PATCH and returns the updated resource. Keeping the transport isolated means the optimistic logic never has to know how requests are made, only what shape the data has. The Todo type is exported so the cache, hook, and component all agree on one contract.

The heart of the pattern lives in useToggleTodo. Optimistic UI means the interface reflects the intended result before the server confirms it, so the app feels instant. React Query enables this through four callbacks. In onMutate, the code first calls cancelQueries to abort any in-flight refetch that could otherwise land after the optimistic write and clobber it. It then snapshots the current cache with getQueryData — this previousTodos value is the rollback point. It writes the predicted next state with setQueryData, flipping completed for the matching id. Crucially, whatever onMutate returns becomes the context argument in the later callbacks.

When the request fails, onError restores previousTodos via setQueryData, undoing the optimistic change so the UI never lies about persisted state. The onSettled callback runs on both success and failure and calls invalidateQueries, forcing a background refetch so the cache reconverges with the server's authoritative truth — this covers subtle drift the optimistic guess didn't capture.

In TodoList, the component reads todos with useQuery and calls mutate(todo.id) on toggle. Because the cache is already updated optimistically, the checkbox flips immediately with no spinner. The isPending state is used only to disable the control, not to gate the visual update.

The main trade-offs: optimistic updates assume success is likely, so they suit low-latency, high-success actions like toggles and likes, not risky writes. The snapshot-and-restore approach also requires that onMutate and onError operate on the exact same query key, or rollback silently misses.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"

const application = Application.start()
application.debug = false

Disable submit button while Turbo form is submitting

rails hotwire stimulus
by codesnips 3 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    url: String,
    delay: { type: Number, default: 800 },

Stimulus: autosave draft with Turbo-friendly requests

rails stimulus hotwire
by codesnips 3 tabs

Share this code

Here's the card — post it anywhere.

Optimistic UI Updates with Rollback Using React Query useMutation — share card
Link copied