Skip to content

Optimistic Updates

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
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] });
}
});

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>
);
}

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>
);
}

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>
);
}

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>
);
}

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”
hooks/useOptimisticMutation.js
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 personalizado
function 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”
  1. Siempre cancelar queries en chapter: Para evitar condiciones de carrera
  2. Guardar snapshot para rollback: Esencial para recuperación de errores
  3. Validar antes de aplicar: Evita estados inconsistentes
  4. Usar flags para identificar datos optimistas: Para UI diferenciada
  5. Implementar rollback granular: Solo revertir lo necesario
  6. Manejar errores específicamente: Diferentes errores, diferentes acciones
  7. Refetch después de errores: Para asegurar consistencia
  8. Usar versioning cuando sea posible: Para detectar conflictos
  9. Proporcionar feedback visual: Mostrar estado optimista vs confirmado
  10. 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