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();
}
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toggleTodo, Todo } from "./todosApi";
const TODOS_KEY = ["todos"] as const;
interface ToggleContext {
previousTodos?: Todo[];
}
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation<Todo, Error, string, ToggleContext>({
mutationFn: toggleTodo,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: TODOS_KEY });
const previousTodos = queryClient.getQueryData<Todo[]>(TODOS_KEY);
queryClient.setQueryData<Todo[]>(TODOS_KEY, (old) =>
(old ?? []).map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
return { previousTodos };
},
onError: (_err, _id, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(TODOS_KEY, context.previousTodos);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: TODOS_KEY });
},
});
}
import { useQuery } from "@tanstack/react-query";
import { fetchTodos, Todo } from "./todosApi";
import { useToggleTodo } from "./useToggleTodo";
export function TodoList() {
const { data: todos, isLoading, isError } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
const toggle = useToggleTodo();
if (isLoading) return <p>Loading…</p>;
if (isError) return <p role="alert">Could not load todos.</p>;
return (
<ul className="todo-list">
{todos?.map((todo: Todo) => (
<li key={todo.id} className={todo.completed ? "done" : ""}>
<label>
<input
type="checkbox"
checked={todo.completed}
disabled={toggle.isPending}
onChange={() => toggle.mutate(todo.id)}
/>
{todo.title}
</label>
</li>
))}
</ul>
);
}
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
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
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
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
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
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
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
Share this code
Here's the card — post it anywhere.