Back to Blog
ReactOptimistic UpdatesUXState Management

React Optimistic Updates

Implement instant UI feedback with optimistic updates. From basic patterns to error handling to rollback strategies.

B
Bootspring Team
Engineering
January 20, 2021
9 min read

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.

Share this article

Help spread the word about Bootspring