PatternsState and Logic
Optimistic UI Patterns
Update UI instantly before server confirms for better UX
Optimistic UI updates the interface immediately, assuming the operation will succeed. If it fails, roll back. This creates instant-feeling apps.
Basic Pattern
export function OptimisticTodo() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = async (text: string) => {
const tempId = crypto.randomUUID();
const newTodo = { id: tempId, text, completed: false };
// Optimistic update
setTodos(prev => [...prev, newTodo]);
try {
// API call
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
});
const saved = await response.json();
// Replace temp with real
setTodos(prev =>
prev.map(t => t.id === tempId ? saved : t)
);
} catch (error) {
// Rollback on error
setTodos(prev => prev.filter(t => t.id !== tempId));
showError('Failed to add todo');
}
};
return <div>{/* UI */}</div>;
}React Query Pattern
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useTodoMutations() {
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (text: string) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
}).then(r => r.json()),
onMutate: async (text) => {
// Cancel ongoing queries
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previous = queryClient.getQueryData(['todos']);
// Optimistic update
queryClient.setQueryData(['todos'], (old: Todo[]) => [
...old,
{ id: 'temp', text, completed: false },
]);
// Return context with snapshot
return { previous };
},
onError: (_err, _variables, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previous);
},
onSettled: () => {
// Refetch after success or error
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return { addTodo };
}Like Button Pattern
export function LikeButton({ postId, initialLikes, initialLiked }: Props) {
const [likes, setLikes] = useState(initialLikes);
const [liked, setLiked] = useState(initialLiked);
const [isUpdating, setIsUpdating] = useState(false);
const toggleLike = async () => {
if (isUpdating) return;
const previousLikes = likes;
const previousLiked = liked;
// Optimistic update
setLiked(!liked);
setLikes(liked ? likes - 1 : likes + 1);
setIsUpdating(true);
try {
await fetch(`/api/posts/${postId}/like`, {
method: liked ? 'DELETE' : 'POST',
});
} catch (error) {
// Rollback
setLiked(previousLiked);
setLikes(previousLikes);
showError('Failed to update like');
} finally {
setIsUpdating(false);
}
};
return (
<button onClick={toggleLike} disabled={isUpdating}>
{liked ? '❤️' : '🤍'} {likes}
</button>
);
}Optimistic Delete with Undo
export function TodoWithUndo() {
const [todos, setTodos] = useState<Todo[]>([]);
const [deletedTodo, setDeletedTodo] = useState<Todo | null>(null);
const timeoutRef = useRef<NodeJS.Timeout>();
const deleteTodo = (id: string) => {
const todo = todos.find(t => t.id === id);
if (!todo) return;
// Optimistic remove
setTodos(prev => prev.filter(t => t.id !== id));
setDeletedTodo(todo);
// Show undo toast
showUndoToast();
// Delete after 5 seconds
timeoutRef.current = setTimeout(async () => {
try {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
setDeletedTodo(null);
} catch (error) {
// Restore on error
setTodos(prev => [...prev, todo]);
setDeletedTodo(null);
showError('Failed to delete');
}
}, 5000);
};
const undo = () => {
if (!deletedTodo) return;
clearTimeout(timeoutRef.current);
setTodos(prev => [...prev, deletedTodo]);
setDeletedTodo(null);
};
return <div>{/* UI with undo button */}</div>;
}Conflict Resolution
export function useOptimisticUpdate() {
const [data, setData] = useState<Data | null>(null);
const [version, setVersion] = useState(0);
const update = async (updates: Partial<Data>) => {
const currentVersion = version;
const previous = data;
// Optimistic update
setData(prev => ({ ...prev, ...updates }));
try {
const response = await fetch('/api/data', {
method: 'PUT',
headers: { 'If-Match': currentVersion.toString() },
body: JSON.stringify(updates),
});
if (response.status === 409) {
// Conflict detected
const serverData = await response.json();
// Show conflict dialog
const resolution = await showConflictDialog(previous, serverData);
if (resolution === 'server') {
setData(serverData);
setVersion(serverData.version);
} else {
// Retry with user changes
await update(updates);
}
} else {
const saved = await response.json();
setData(saved);
setVersion(saved.version);
}
} catch (error) {
// Rollback
setData(previous);
showError('Update failed');
}
};
return { data, update };
}Loading States
export function OptimisticWithLoading() {
const [items, setItems] = useState<Item[]>([]);
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set());
const addItem = async (text: string) => {
const tempId = `temp-${Date.now()}`;
// Add to pending
setPendingIds(prev => new Set([...prev, tempId]));
// Optimistic add
setItems(prev => [...prev, { id: tempId, text }]);
try {
const response = await fetch('/api/items', {
method: 'POST',
body: JSON.stringify({ text }),
});
const saved = await response.json();
// Replace temp with real
setItems(prev =>
prev.map(item => item.id === tempId ? saved : item)
);
} catch (error) {
// Remove on error
setItems(prev => prev.filter(item => item.id !== tempId));
} finally {
// Remove from pending
setPendingIds(prev => {
const next = new Set(prev);
next.delete(tempId);
return next;
});
}
};
return (
<ul>
{items.map(item => (
<li
key={item.id}
style={{
opacity: pendingIds.has(item.id) ? 0.5 : 1,
}}
>
{item.text}
{pendingIds.has(item.id) && <span>⏳</span>}
</li>
))}
</ul>
);
}Best Practices
- Show pending state: Opacity, spinner, etc.
- Rollback on error: Restore previous state
- Show error messages: Tell user what failed
- Debounce updates: Don't spam server
- Handle conflicts: Detect version mismatches
- Undo option: For destructive actions
- Cancel ongoing: Prevent race conditions
- Test failures: Verify rollback works
- Preserve user input: Don't lose their work
- Be honest: Show when operation is pending
Optimistic UI makes apps feel instant—implement carefully with proper rollback and error handling!