Optimistic updates show changes instantly before server confirmation. Here's how to implement them.
Why Optimistic Updates?#
1// Without optimistic updates - feels slow
2async function addTodo(text: string) {
3 setLoading(true);
4 const todo = await api.createTodo(text); // Wait for server
5 setTodos(prev => [...prev, todo]);
6 setLoading(false);
7}
8
9// With optimistic updates - instant feedback
10async function addTodoOptimistic(text: string) {
11 const tempId = Date.now().toString();
12 const optimisticTodo = { id: tempId, text, completed: false };
13
14 // Update UI immediately
15 setTodos(prev => [...prev, optimisticTodo]);
16
17 try {
18 // Sync with server
19 const realTodo = await api.createTodo(text);
20 setTodos(prev => prev.map(t =>
21 t.id === tempId ? realTodo : t
22 ));
23 } catch (error) {
24 // Rollback on failure
25 setTodos(prev => prev.filter(t => t.id !== tempId));
26 showError('Failed to create todo');
27 }
28}Basic Implementation#
1import { useState, useCallback } from 'react';
2
3interface Todo {
4 id: string;
5 text: string;
6 completed: boolean;
7 isPending?: boolean;
8}
9
10function TodoList() {
11 const [todos, setTodos] = useState<Todo[]>([]);
12
13 const addTodo = useCallback(async (text: string) => {
14 const tempId = `temp-${Date.now()}`;
15 const optimisticTodo: Todo = {
16 id: tempId,
17 text,
18 completed: false,
19 isPending: true,
20 };
21
22 // Optimistic update
23 setTodos(prev => [...prev, optimisticTodo]);
24
25 try {
26 const todo = await api.createTodo(text);
27 setTodos(prev =>
28 prev.map(t => (t.id === tempId ? { ...todo, isPending: false } : t))
29 );
30 } catch {
31 // Rollback
32 setTodos(prev => prev.filter(t => t.id !== tempId));
33 }
34 }, []);
35
36 const toggleTodo = useCallback(async (id: string) => {
37 const todo = todos.find(t => t.id === id);
38 if (!todo) return;
39
40 // Optimistic update
41 setTodos(prev =>
42 prev.map(t =>
43 t.id === id ? { ...t, completed: !t.completed, isPending: true } : t
44 )
45 );
46
47 try {
48 await api.updateTodo(id, { completed: !todo.completed });
49 setTodos(prev =>
50 prev.map(t => (t.id === id ? { ...t, isPending: false } : t))
51 );
52 } catch {
53 // Rollback
54 setTodos(prev =>
55 prev.map(t =>
56 t.id === id ? { ...t, completed: todo.completed, isPending: false } : t
57 )
58 );
59 }
60 }, [todos]);
61
62 const deleteTodo = useCallback(async (id: string) => {
63 const todo = todos.find(t => t.id === id);
64 if (!todo) return;
65
66 // Optimistic delete
67 setTodos(prev => prev.filter(t => t.id !== id));
68
69 try {
70 await api.deleteTodo(id);
71 } catch {
72 // Rollback
73 setTodos(prev => [...prev, todo]);
74 }
75 }, [todos]);
76
77 return (
78 <ul>
79 {todos.map(todo => (
80 <li
81 key={todo.id}
82 style={{ opacity: todo.isPending ? 0.5 : 1 }}
83 >
84 <input
85 type="checkbox"
86 checked={todo.completed}
87 onChange={() => toggleTodo(todo.id)}
88 disabled={todo.isPending}
89 />
90 {todo.text}
91 <button onClick={() => deleteTodo(todo.id)}>Delete</button>
92 </li>
93 ))}
94 </ul>
95 );
96}Custom Hook#
1interface UseOptimisticOptions<T, TCreate, TUpdate> {
2 fetchItems: () => Promise<T[]>;
3 createItem: (data: TCreate) => Promise<T>;
4 updateItem: (id: string, data: TUpdate) => Promise<T>;
5 deleteItem: (id: string) => Promise<void>;
6 getId: (item: T) => string;
7}
8
9function useOptimisticCRUD<T, TCreate, TUpdate>({
10 fetchItems,
11 createItem,
12 updateItem,
13 deleteItem,
14 getId,
15}: UseOptimisticOptions<T, TCreate, TUpdate>) {
16 const [items, setItems] = useState<(T & { _pending?: boolean })[]>([]);
17 const [error, setError] = useState<Error | null>(null);
18 const [loading, setLoading] = useState(true);
19
20 useEffect(() => {
21 fetchItems()
22 .then(setItems)
23 .catch(setError)
24 .finally(() => setLoading(false));
25 }, [fetchItems]);
26
27 const create = useCallback(async (
28 data: TCreate,
29 optimisticData: Partial<T>
30 ) => {
31 const tempId = `temp-${Date.now()}`;
32 const optimisticItem = {
33 ...optimisticData,
34 _pending: true,
35 } as T & { _pending: boolean };
36
37 setItems(prev => [...prev, optimisticItem]);
38
39 try {
40 const created = await createItem(data);
41 setItems(prev =>
42 prev.map(item =>
43 getId(item) === tempId ? { ...created, _pending: false } : item
44 )
45 );
46 return created;
47 } catch (err) {
48 setItems(prev => prev.filter(item => getId(item) !== tempId));
49 throw err;
50 }
51 }, [createItem, getId]);
52
53 const update = useCallback(async (
54 id: string,
55 data: TUpdate
56 ) => {
57 const original = items.find(item => getId(item) === id);
58 if (!original) return;
59
60 setItems(prev =>
61 prev.map(item =>
62 getId(item) === id ? { ...item, ...data, _pending: true } : item
63 )
64 );
65
66 try {
67 const updated = await updateItem(id, data);
68 setItems(prev =>
69 prev.map(item =>
70 getId(item) === id ? { ...updated, _pending: false } : item
71 )
72 );
73 return updated;
74 } catch (err) {
75 setItems(prev =>
76 prev.map(item =>
77 getId(item) === id ? { ...original, _pending: false } : item
78 )
79 );
80 throw err;
81 }
82 }, [items, updateItem, getId]);
83
84 const remove = useCallback(async (id: string) => {
85 const original = items.find(item => getId(item) === id);
86 if (!original) return;
87
88 setItems(prev => prev.filter(item => getId(item) !== id));
89
90 try {
91 await deleteItem(id);
92 } catch (err) {
93 setItems(prev => {
94 const index = prev.findIndex(item => getId(item) > id);
95 const newItems = [...prev];
96 newItems.splice(index === -1 ? prev.length : index, 0, original);
97 return newItems;
98 });
99 throw err;
100 }
101 }, [items, deleteItem, getId]);
102
103 return {
104 items,
105 loading,
106 error,
107 create,
108 update,
109 remove,
110 };
111}With React Query#
1import { useMutation, useQueryClient } from '@tanstack/react-query';
2
3function useTodos() {
4 const queryClient = useQueryClient();
5
6 const addMutation = useMutation({
7 mutationFn: (text: string) => api.createTodo(text),
8 onMutate: async (text) => {
9 // Cancel outgoing refetches
10 await queryClient.cancelQueries({ queryKey: ['todos'] });
11
12 // Snapshot previous value
13 const previousTodos = queryClient.getQueryData(['todos']);
14
15 // Optimistic update
16 queryClient.setQueryData(['todos'], (old: Todo[]) => [
17 ...old,
18 { id: `temp-${Date.now()}`, text, completed: false },
19 ]);
20
21 // Return context for rollback
22 return { previousTodos };
23 },
24 onError: (err, text, context) => {
25 // Rollback
26 queryClient.setQueryData(['todos'], context?.previousTodos);
27 },
28 onSettled: () => {
29 // Refetch to ensure consistency
30 queryClient.invalidateQueries({ queryKey: ['todos'] });
31 },
32 });
33
34 const toggleMutation = useMutation({
35 mutationFn: ({ id, completed }: { id: string; completed: boolean }) =>
36 api.updateTodo(id, { completed }),
37 onMutate: async ({ id, completed }) => {
38 await queryClient.cancelQueries({ queryKey: ['todos'] });
39
40 const previousTodos = queryClient.getQueryData(['todos']);
41
42 queryClient.setQueryData(['todos'], (old: Todo[]) =>
43 old.map(todo =>
44 todo.id === id ? { ...todo, completed } : todo
45 )
46 );
47
48 return { previousTodos };
49 },
50 onError: (err, variables, context) => {
51 queryClient.setQueryData(['todos'], context?.previousTodos);
52 },
53 onSettled: () => {
54 queryClient.invalidateQueries({ queryKey: ['todos'] });
55 },
56 });
57
58 const deleteMutation = useMutation({
59 mutationFn: (id: string) => api.deleteTodo(id),
60 onMutate: async (id) => {
61 await queryClient.cancelQueries({ queryKey: ['todos'] });
62
63 const previousTodos = queryClient.getQueryData(['todos']);
64
65 queryClient.setQueryData(['todos'], (old: Todo[]) =>
66 old.filter(todo => todo.id !== id)
67 );
68
69 return { previousTodos };
70 },
71 onError: (err, id, context) => {
72 queryClient.setQueryData(['todos'], context?.previousTodos);
73 },
74 onSettled: () => {
75 queryClient.invalidateQueries({ queryKey: ['todos'] });
76 },
77 });
78
79 return { addMutation, toggleMutation, deleteMutation };
80}With Zustand#
1import { create } from 'zustand';
2import { immer } from 'zustand/middleware/immer';
3
4interface Todo {
5 id: string;
6 text: string;
7 completed: boolean;
8 isPending?: boolean;
9}
10
11interface TodoStore {
12 todos: Todo[];
13 addTodo: (text: string) => Promise<void>;
14 toggleTodo: (id: string) => Promise<void>;
15 deleteTodo: (id: string) => Promise<void>;
16}
17
18const useTodoStore = create<TodoStore>()(
19 immer((set, get) => ({
20 todos: [],
21
22 addTodo: async (text) => {
23 const tempId = `temp-${Date.now()}`;
24
25 // Optimistic add
26 set(state => {
27 state.todos.push({
28 id: tempId,
29 text,
30 completed: false,
31 isPending: true,
32 });
33 });
34
35 try {
36 const todo = await api.createTodo(text);
37 set(state => {
38 const index = state.todos.findIndex(t => t.id === tempId);
39 if (index !== -1) {
40 state.todos[index] = { ...todo, isPending: false };
41 }
42 });
43 } catch {
44 // Rollback
45 set(state => {
46 state.todos = state.todos.filter(t => t.id !== tempId);
47 });
48 }
49 },
50
51 toggleTodo: async (id) => {
52 const todo = get().todos.find(t => t.id === id);
53 if (!todo) return;
54
55 const previousCompleted = todo.completed;
56
57 // Optimistic toggle
58 set(state => {
59 const t = state.todos.find(t => t.id === id);
60 if (t) {
61 t.completed = !t.completed;
62 t.isPending = true;
63 }
64 });
65
66 try {
67 await api.updateTodo(id, { completed: !previousCompleted });
68 set(state => {
69 const t = state.todos.find(t => t.id === id);
70 if (t) t.isPending = false;
71 });
72 } catch {
73 // Rollback
74 set(state => {
75 const t = state.todos.find(t => t.id === id);
76 if (t) {
77 t.completed = previousCompleted;
78 t.isPending = false;
79 }
80 });
81 }
82 },
83
84 deleteTodo: async (id) => {
85 const todos = get().todos;
86 const todo = todos.find(t => t.id === id);
87 const index = todos.findIndex(t => t.id === id);
88
89 // Optimistic delete
90 set(state => {
91 state.todos = state.todos.filter(t => t.id !== id);
92 });
93
94 try {
95 await api.deleteTodo(id);
96 } catch {
97 // Rollback
98 set(state => {
99 if (todo) {
100 state.todos.splice(index, 0, todo);
101 }
102 });
103 }
104 },
105 }))
106);Handling Race Conditions#
1function useOptimisticUpdate<T>(
2 key: string,
3 fetcher: () => Promise<T>
4) {
5 const [data, setData] = useState<T | null>(null);
6 const versionRef = useRef(0);
7
8 const update = useCallback(async (
9 optimisticData: T,
10 serverUpdate: () => Promise<T>
11 ) => {
12 const version = ++versionRef.current;
13
14 // Optimistic update
15 setData(optimisticData);
16
17 try {
18 const result = await serverUpdate();
19
20 // Only apply if this is still the latest update
21 if (version === versionRef.current) {
22 setData(result);
23 }
24 } catch (error) {
25 // Only rollback if this is still the latest update
26 if (version === versionRef.current) {
27 const fresh = await fetcher();
28 setData(fresh);
29 }
30 throw error;
31 }
32 }, [fetcher]);
33
34 return { data, update };
35}Visual Feedback#
1function OptimisticItem({ item, onDelete }: {
2 item: Todo & { isPending?: boolean };
3 onDelete: () => void;
4}) {
5 return (
6 <div
7 className={`
8 item
9 ${item.isPending ? 'item--pending' : ''}
10 `}
11 >
12 <span className="item-text">{item.text}</span>
13
14 {item.isPending && (
15 <span className="item-status">
16 <Spinner size="small" />
17 Saving...
18 </span>
19 )}
20
21 <button
22 onClick={onDelete}
23 disabled={item.isPending}
24 >
25 Delete
26 </button>
27 </div>
28 );
29}
30
31// CSS
32const styles = `
33 .item {
34 transition: opacity 0.2s;
35 }
36
37 .item--pending {
38 opacity: 0.6;
39 pointer-events: none;
40 }
41
42 .item--pending .item-text {
43 font-style: italic;
44 }
45`;Error Recovery UI#
1function TodoWithRetry({ todo, onRetry, onDiscard }: {
2 todo: Todo & { error?: Error };
3 onRetry: () => void;
4 onDiscard: () => void;
5}) {
6 if (todo.error) {
7 return (
8 <div className="todo todo--error">
9 <span>{todo.text}</span>
10 <div className="error-actions">
11 <span className="error-message">
12 Failed to save
13 </span>
14 <button onClick={onRetry}>Retry</button>
15 <button onClick={onDiscard}>Discard</button>
16 </div>
17 </div>
18 );
19 }
20
21 return (
22 <div className="todo">
23 <span>{todo.text}</span>
24 </div>
25 );
26}Best Practices#
Implementation:
✓ Use unique temporary IDs
✓ Track pending state visually
✓ Handle race conditions
✓ Provide rollback mechanism
UX:
✓ Show visual feedback for pending items
✓ Disable interactions during pending
✓ Offer retry on failure
✓ Consider undo functionality
Error Handling:
✓ Rollback on failure
✓ Show clear error messages
✓ Allow manual retry
✓ Log failures for debugging
Performance:
✓ Batch related updates
✓ Debounce rapid changes
✓ Cancel pending requests on new updates
✓ Invalidate and refetch after mutations
Conclusion#
Optimistic updates provide instant feedback while syncing with the server. Implement proper rollback for failures, show visual feedback for pending states, and handle race conditions. Use libraries like React Query for built-in support and consistent patterns.