Optimistic Updates
Optimistic Updates: UI Instantánea
Section titled “Optimistic Updates: UI Instantánea”Las actualizaciones optimistas permiten actualizar la interfaz inmediatamente antes de confirmar los cambios con el servidor, proporcionando una experiencia de usuario súper fluida. TanStack Query hace esto increíblemente fácil y seguro.
🎯 ¿Qué son las Actualizaciones Optimistas?
Section titled “🎯 ¿Qué son las Actualizaciones Optimistas?”En lugar de esperar la confirmación del servidor:
// ❌ Flujo tradicional (lento)// 1. Usuario hace clic → 2. Loading... → 3. Respuesta servidor → 4. UI actualizada
// ✅ Flujo optimista (instantáneo)// 1. Usuario hace clic → 2. UI actualizada inmediatamente → 3. Confirmación servidor
Anatomía de una Actualización Optimista
Section titled “Anatomía de una Actualización Optimista”const updatePostMutation = useMutation({ mutationFn: updatePost,
// 1. ANTES de la petición al servidor onMutate: async (variables) => { // Cancelar refetches en chapter await queryClient.cancelQueries({ queryKey: ['post', variables.id] });
// Snapshot del estado anterior const previousPost = queryClient.getQueryData(['post', variables.id]);
// Actualización optimista queryClient.setQueryData(['post', variables.id], (old) => ({ ...old, ...variables, updatedAt: new Date().toISOString() }));
// Devolver contexto para rollback return { previousPost }; },
// 2. Si la mutación FALLA onError: (err, variables, context) => { // Rollback al estado anterior queryClient.setQueryData(['post', variables.id], context.previousPost); },
// 3. Siempre al final (éxito o error) onSettled: (data, error, variables) => { // Refetch para sincronizar con servidor queryClient.invalidateQueries({ queryKey: ['post', variables.id] }); }});
❤️ Like Button Optimista
Section titled “❤️ Like Button Optimista”Ejemplo clásico de un botón de “me gusta”:
function OptimisticLikeButton({ post }) { const queryClient = useQueryClient();
const likeMutation = useMutation({ mutationFn: async ({ postId, isLiked }) => { // Simular delay de red await new Promise(resolve => setTimeout(resolve, 1000));
const response = await fetch(`/api/posts/${postId}/like`, { method: isLiked ? 'DELETE' : 'POST', });
if (!response.ok) { throw new Error('Failed to update like'); }
return response.json(); },
onMutate: async ({ postId, isLiked }) => { // Cancelar queries en chapter await queryClient.cancelQueries({ queryKey: ['post', postId] });
// Snapshot del estado anterior const previousPost = queryClient.getQueryData(['post', postId]);
// Actualización optimista queryClient.setQueryData(['post', postId], (old) => ({ ...old, isLiked: !isLiked, likesCount: isLiked ? old.likesCount - 1 : old.likesCount + 1 }));
// También actualizar en la lista de posts queryClient.setQueriesData( { queryKey: ['posts'] }, (oldPosts) => oldPosts?.map(p => p.id === postId ? { ...p, isLiked: !isLiked, likesCount: isLiked ? p.likesCount - 1 : p.likesCount + 1 } : p ) );
return { previousPost }; },
onError: (err, { postId }, context) => { // Rollback if (context?.previousPost) { queryClient.setQueryData(['post', postId], context.previousPost); }
// También rollback en la lista queryClient.invalidateQueries({ queryKey: ['posts'] });
// Mostrar error al usuario toast.error('Error al actualizar el like'); },
onSuccess: (data, { postId }) => { // Actualizar con datos reales del servidor queryClient.setQueryData(['post', postId], data); } });
const handleLike = () => { likeMutation.mutate({ postId: post.id, isLiked: post.isLiked }); };
return ( <button onClick={handleLike} disabled={likeMutation.isLoading} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 12px', border: 'none', borderRadius: '20px', backgroundColor: post.isLiked ? '#ff6b6b' : '#f8f9fa', color: post.isLiked ? 'white' : '#333', cursor: 'pointer', transition: 'all 0.2s ease' }} > <span>{post.isLiked ? '❤️' : '🤍'}</span> <span>{post.likesCount}</span> {likeMutation.isLoading && <span>⏳</span>} </button> );}
📝 Edición Inline Optimista
Section titled “📝 Edición Inline Optimista”Editor inline que actualiza inmediatamente:
function OptimisticInlineEditor({ post }) { const [isEditing, setIsEditing] = useState(false); const [title, setTitle] = useState(post.title); const queryClient = useQueryClient();
const updateMutation = useMutation({ mutationFn: async ({ id, title }) => { const response = await fetch(`/api/posts/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }), });
if (!response.ok) { throw new Error('Failed to update post'); }
return response.json(); },
onMutate: async ({ id, title }) => { // Cancelar refetches await queryClient.cancelQueries({ queryKey: ['post', id] });
// Snapshot const previousPost = queryClient.getQueryData(['post', id]);
// Actualización optimista queryClient.setQueryData(['post', id], (old) => ({ ...old, title, updatedAt: new Date().toISOString() }));
// Actualizar en listas también queryClient.setQueriesData( { queryKey: ['posts'] }, (oldPosts) => oldPosts?.map(p => p.id === id ? { ...p, title } : p ) );
return { previousPost, previousTitle: previousPost?.title }; },
onError: (err, { id }, context) => { // Rollback if (context?.previousPost) { queryClient.setQueryData(['post', id], context.previousPost); }
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Restaurar el input setTitle(context?.previousTitle || '');
toast.error('Error al actualizar el título'); },
onSuccess: () => { setIsEditing(false); toast.success('Título actualizado'); } });
const handleSave = () => { if (title.trim() !== post.title) { updateMutation.mutate({ id: post.id, title: title.trim() }); } else { setIsEditing(false); } };
const handleCancel = () => { setTitle(post.title); setIsEditing(false); };
if (isEditing) { return ( <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <input value={title} onChange={(e) => setTitle(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }} style={{ flex: 1, padding: '8px', border: '2px solid #007bff', borderRadius: '4px', fontSize: '18px', fontWeight: 'bold' }} autoFocus /> <button onClick={handleSave} disabled={updateMutation.isLoading} style={{ padding: '8px 12px', backgroundColor: '#28a745', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }} > {updateMutation.isLoading ? '⏳' : '✅'} </button> <button onClick={handleCancel} disabled={updateMutation.isLoading} style={{ padding: '8px 12px', backgroundColor: '#6c757d', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }} > ❌ </button> </div> ); }
return ( <h2 onClick={() => setIsEditing(true)} style={{ cursor: 'pointer', padding: '8px', borderRadius: '4px', transition: 'background-color 0.2s', ':hover': { backgroundColor: '#f8f9fa' } }} title="Click para editar" > {post.title} <small style={{ marginLeft: '10px', color: '#666' }}>✏️</small> </h2> );}
📋 Lista de Tareas Optimista
Section titled “📋 Lista de Tareas Optimista”Todo list con actualizaciones instantáneas:
function OptimisticTodoList() { const queryClient = useQueryClient();
// Query para obtener todos const { data: todos = [], isLoading } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// Agregar todo const addTodoMutation = useMutation({ mutationFn: createTodo, onMutate: async (newTodo) => { await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
// Crear todo temporal con ID temporal const optimisticTodo = { id: `temp-${Date.now()}`, ...newTodo, completed: false, createdAt: new Date().toISOString(), isOptimistic: true // Flag para identificar datos optimistas };
queryClient.setQueryData(['todos'], (old) => [optimisticTodo, ...old]);
return { previousTodos }; },
onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context.previousTodos); toast.error('Error al crear la tarea'); },
onSuccess: (serverTodo, variables, context) => { // Reemplazar todo optimista con datos reales del servidor queryClient.setQueryData(['todos'], (old) => old.map(todo => todo.isOptimistic && todo.text === serverTodo.text ? serverTodo : todo ) ); } });
// Alternar completado const toggleTodoMutation = useMutation({ mutationFn: updateTodo, onMutate: async ({ id, completed }) => { await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => old.map(todo => todo.id === id ? { ...todo, completed, updatedAt: new Date().toISOString() } : todo ) );
return { previousTodos }; },
onError: (err, variables, context) => { queryClient.setQueryData(['todos'], context.previousTodos); toast.error('Error al actualizar la tarea'); } });
// Eliminar todo const deleteTodoMutation = useMutation({ mutationFn: deleteTodo, onMutate: async (todoId) => { await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => old.filter(todo => todo.id !== todoId) );
return { previousTodos }; },
onError: (err, todoId, context) => { queryClient.setQueryData(['todos'], context.previousTodos); toast.error('Error al eliminar la tarea'); } });
const [newTodoText, setNewTodoText] = useState('');
const handleAddTodo = (e) => { e.preventDefault(); if (newTodoText.trim()) { addTodoMutation.mutate({ text: newTodoText.trim() }); setNewTodoText(''); } };
if (isLoading) return <div>Cargando tareas...</div>;
return ( <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}> <h2>📋 Lista de Tareas Optimista</h2>
{/* Formulario para agregar */} <form onSubmit={handleAddTodo} style={{ marginBottom: '20px' }}> <div style={{ display: 'flex', gap: '10px' }}> <input type="text" value={newTodoText} onChange={(e) => setNewTodoText(e.target.value)} placeholder="Nueva tarea..." style={{ flex: 1, padding: '12px', border: '1px solid #ddd', borderRadius: '4px', fontSize: '16px' }} /> <button type="submit" disabled={addTodoMutation.isLoading} style={{ padding: '12px 20px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }} > {addTodoMutation.isLoading ? '⏳' : '➕ Agregar'} </button> </div> </form>
{/* Lista de todos */} <div> {todos.map(todo => ( <div key={todo.id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '12px', border: '1px solid #ddd', borderRadius: '8px', marginBottom: '8px', backgroundColor: todo.isOptimistic ? '#fff3cd' : 'white', opacity: todo.isOptimistic ? 0.8 : 1 }} > <input type="checkbox" checked={todo.completed} onChange={(e) => toggleTodoMutation.mutate({ id: todo.id, completed: e.target.checked }) } style={{ transform: 'scale(1.2)' }} />
<span style={{ flex: 1, textDecoration: todo.completed ? 'line-through' : 'none', color: todo.completed ? '#666' : '#333', fontSize: '16px' }} > {todo.text} </span>
{todo.isOptimistic && ( <span style={{ fontSize: '12px', color: '#856404' }}> ⏳ Guardando... </span> )}
<button onClick={() => deleteTodoMutation.mutate(todo.id)} disabled={deleteTodoMutation.isLoading} style={{ padding: '6px 10px', backgroundColor: '#dc3545', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' }} > 🗑️ </button> </div> ))} </div>
{todos.length === 0 && ( <div style={{ textAlign: 'center', color: '#666', padding: '40px' }}> 📝 No hay tareas. ¡Agrega una nueva! </div> )} </div> );}
🔄 Batch Updates Optimista
Section titled “🔄 Batch Updates Optimista”Para múltiples actualizaciones simultáneas:
function OptimisticBatchUpdates() { const queryClient = useQueryClient();
const batchUpdateMutation = useMutation({ mutationFn: async (updates) => { // Enviar todas las actualizaciones al servidor const response = await fetch('/api/posts/batch-update', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates }), });
return response.json(); },
onMutate: async (updates) => { // Cancelar queries relacionadas await queryClient.cancelQueries({ queryKey: ['posts'] });
const previousPosts = queryClient.getQueryData(['posts']);
// Aplicar todas las actualizaciones optimistamente queryClient.setQueryData(['posts'], (oldPosts) => oldPosts.map(post => { const update = updates.find(u => u.id === post.id); return update ? { ...post, ...update.changes } : post; }) );
// También actualizar posts individuales updates.forEach(({ id, changes }) => { queryClient.setQueryData(['post', id], (oldPost) => oldPost ? { ...oldPost, ...changes } : undefined ); });
return { previousPosts }; },
onError: (err, updates, context) => { // Rollback completo queryClient.setQueryData(['posts'], context.previousPosts);
// Rollback posts individuales updates.forEach(({ id }) => { queryClient.invalidateQueries({ queryKey: ['post', id] }); });
toast.error('Error al actualizar posts'); },
onSuccess: (updatedPosts) => { // Actualizar con datos reales del servidor updatedPosts.forEach(post => { queryClient.setQueryData(['post', post.id], post); }); } });
const handleBulkArchive = (postIds) => { const updates = postIds.map(id => ({ id, changes: { archived: true, archivedAt: new Date().toISOString() } }));
batchUpdateMutation.mutate(updates); };
const handleBulkPublish = (postIds) => { const updates = postIds.map(id => ({ id, changes: { published: true, publishedAt: new Date().toISOString() } }));
batchUpdateMutation.mutate(updates); };
return ( <div> <button onClick={() => handleBulkArchive([1, 2, 3])}> 📁 Archivar Seleccionados </button> <button onClick={() => handleBulkPublish([4, 5, 6])}> 📢 Publicar Seleccionados </button> </div> );}
🛡️ Optimistic Updates Seguros
Section titled “🛡️ Optimistic Updates Seguros”Patrones para manejar casos complejos:
function SafeOptimisticUpdates() { const queryClient = useQueryClient();
const updatePostMutation = useMutation({ mutationFn: updatePost,
onMutate: async (variables) => { const { id, ...changes } = variables;
// 1. Cancelar refetches en chapter await queryClient.cancelQueries({ queryKey: ['post', id] });
// 2. Validar datos antes de aplicar cambios optimistas const currentPost = queryClient.getQueryData(['post', id]); if (!currentPost) { throw new Error('Post not found in cache'); }
// 3. Validaciones de negocio if (changes.title && changes.title.length > 200) { throw new Error('Title too long'); }
if (changes.published && !currentPost.content) { throw new Error('Cannot publish post without content'); }
// 4. Snapshot para rollback const previousPost = { ...currentPost };
// 5. Aplicar cambios optimistas con validación const updatedPost = { ...currentPost, ...changes, updatedAt: new Date().toISOString(), version: (currentPost.version || 0) + 1 // Optimistic versioning };
// 6. Actualizar cache queryClient.setQueryData(['post', id], updatedPost);
// 7. Actualizar en listas con verificación queryClient.setQueriesData( { queryKey: ['posts'] }, (oldPosts) => { if (!oldPosts) return oldPosts;
return oldPosts.map(post => post.id === id ? updatedPost : post ); } );
return { previousPost, optimisticPost: updatedPost }; },
onError: (error, variables, context) => { const { id } = variables;
// Rollback granular if (context?.previousPost) { queryClient.setQueryData(['post', id], context.previousPost);
// Rollback en listas queryClient.setQueriesData( { queryKey: ['posts'] }, (oldPosts) => oldPosts?.map(post => post.id === id ? context.previousPost : post ) ); }
// Manejo específico de errores if (error.message.includes('version')) { toast.error('El post fue modificado por otro usuario. Recargando...'); queryClient.invalidateQueries({ queryKey: ['post', id] }); } else if (error.message.includes('permission')) { toast.error('No tienes permisos para editar este post'); } else { toast.error(`Error: ${error.message}`); } },
onSuccess: (serverPost, variables, context) => { // Verificar que la respuesta del servidor coincida con nuestras expectativas if (context?.optimisticPost && serverPost.version !== context.optimisticPost.version) { console.warn('Server version differs from optimistic version'); }
// Actualizar con datos autoritativos del servidor queryClient.setQueryData(['post', variables.id], serverPost);
toast.success('Post actualizado correctamente'); },
onSettled: (data, error, variables) => { // Siempre refetch para asegurar consistencia queryClient.invalidateQueries({ queryKey: ['post', variables.id], refetchType: 'active' }); } });
return ( <div> {/* Tu componente aquí */} </div> );}
📊 Hook Personalizado para Optimistic Updates
Section titled “📊 Hook Personalizado para Optimistic Updates”export function useOptimisticMutation({ mutationFn, queryKey, updateFn, rollbackFn, onError, onSuccess}) { const queryClient = useQueryClient();
return useMutation({ mutationFn,
onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
if (updateFn) { const optimisticData = updateFn(previousData, variables); queryClient.setQueryData(queryKey, optimisticData); }
return { previousData }; },
onError: (error, variables, context) => { if (rollbackFn && context?.previousData) { queryClient.setQueryData(queryKey, context.previousData); }
onError?.(error, variables, context); },
onSuccess: (data, variables, context) => { onSuccess?.(data, variables, context); },
onSettled: () => { queryClient.invalidateQueries({ queryKey }); } });}
// Uso del hook personalizadofunction PostLikeButton({ post }) { const likeMutation = useOptimisticMutation({ mutationFn: ({ postId, isLiked }) => toggleLikeAPI(postId, isLiked), queryKey: ['post', post.id], updateFn: (previousPost, { isLiked }) => ({ ...previousPost, isLiked: !isLiked, likesCount: isLiked ? previousPost.likesCount - 1 : previousPost.likesCount + 1 }), onError: (error) => { toast.error('Error al actualizar el like'); }, onSuccess: () => { toast.success('Like actualizado'); } });
return ( <button onClick={() => likeMutation.mutate({ postId: post.id, isLiked: post.isLiked })}> {post.isLiked ? '❤️' : '🤍'} {post.likesCount} </button> );}
✅ Mejores Prácticas para Optimistic Updates
Section titled “✅ Mejores Prácticas para Optimistic Updates”- Siempre cancelar queries en chapter: Para evitar condiciones de carrera
- Guardar snapshot para rollback: Esencial para recuperación de errores
- Validar antes de aplicar: Evita estados inconsistentes
- Usar flags para identificar datos optimistas: Para UI diferenciada
- Implementar rollback granular: Solo revertir lo necesario
- Manejar errores específicamente: Diferentes errores, diferentes acciones
- Refetch después de errores: Para asegurar consistencia
- Usar versioning cuando sea posible: Para detectar conflictos
- Proporcionar feedback visual: Mostrar estado optimista vs confirmado
- Considerar batch updates: Para múltiples cambios simultáneos
¡Perfecto! Ahora dominas las actualizaciones optimistas. Continuemos con el background refetching para datos siempre actualizados.
Próximo paso: Background Refetching