useMutation
useMutation: Modificando Datos
Section titled “useMutation: Modificando Datos”useMutation
es el hook de TanStack Query para realizar operaciones que modifican datos en el servidor (POST, PUT, DELETE). A diferencia de useQuery
, las mutaciones se ejecutan manualmente y no se cachean automáticamente.
🎯 Anatomía de useMutation
Section titled “🎯 Anatomía de useMutation”const { mutate, // Función para ejecutar la mutación mutateAsync, // Versión async de mutate (retorna Promise) data, // Datos devueltos por la mutación isLoading, // Si la mutación está en progreso isError, // Si hubo error error, // El objeto error isSuccess, // Si fue exitosa reset, // Resetear estado de la mutación // ... más propiedades} = useMutation({ mutationFn, // Función que ejecuta la mutación // ... opciones});
🚀 Mutación Básica
Section titled “🚀 Mutación Básica”Crear un Post
Section titled “Crear un Post”import { useMutation } from '@tanstack/react-query';
function CreatePost() { const { mutate: createPost, isLoading, error, isSuccess, data } = useMutation({ mutationFn: async (newPost) => { const response = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(newPost), });
if (!response.ok) { throw new Error('Error creating post'); }
return response.json(); }, });
const handleSubmit = (e) => { e.preventDefault(); const formData = new FormData(e.target);
createPost({ title: formData.get('title'), body: formData.get('body'), userId: 1 }); };
return ( <div> <h2>Crear Nuevo Post</h2>
{isSuccess && ( <div style={{ color: 'green', margin: '10px 0' }}> ✅ Post creado exitosamente! ID: {data?.id} </div> )}
{error && ( <div style={{ color: 'red', margin: '10px 0' }}> ❌ Error: {error.message} </div> )}
<form onSubmit={handleSubmit}> <div style={{ margin: '10px 0' }}> <input name="title" placeholder="Título del post" required style={{ width: '100%', padding: '8px' }} /> </div>
<div style={{ margin: '10px 0' }}> <textarea name="body" placeholder="Contenido del post" required rows={4} style={{ width: '100%', padding: '8px' }} /> </div>
<button type="submit" disabled={isLoading} style={{ padding: '10px 20px', backgroundColor: isLoading ? '#ccc' : '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: isLoading ? 'not-allowed' : 'pointer' }} > {isLoading ? '⏳ Creando...' : '✍️ Crear Post'} </button> </form> </div> );}
🔄 Invalidación Automática del Cache
Section titled “🔄 Invalidación Automática del Cache”Después de una mutación exitosa, normalmente quieres actualizar los datos relacionados:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePost() { const queryClient = useQueryClient();
const { mutate: createPost, isLoading } = useMutation({ mutationFn: async (newPost) => { const response = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newPost), }); return response.json(); },
// Callbacks del ciclo de vida onSuccess: (data) => { // Invalidar y refetch queries relacionadas queryClient.invalidateQueries({ queryKey: ['posts'] });
// También puedes invalidar queries específicas queryClient.invalidateQueries({ queryKey: ['posts', 'user', data.userId] }); },
onError: (error) => { console.error('Error creating post:', error); },
onSettled: () => { // Se ejecuta siempre (éxito o error) console.log('Mutation completed'); } });
// ... resto del componente}
🎯 Mutaciones con Variables
Section titled “🎯 Mutaciones con Variables”Actualizar un Post
Section titled “Actualizar un Post”function EditPost({ postId, initialData }) { const queryClient = useQueryClient();
const { mutate: updatePost, isLoading, error } = useMutation({ mutationFn: async ({ id, ...updateData }) => { const response = await fetch(`/api/posts/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateData), });
if (!response.ok) { throw new Error('Failed to update post'); }
return response.json(); },
onSuccess: (data, variables) => { // Actualizar el post específico en el cache queryClient.setQueryData(['post', variables.id], data);
// Invalidar lista de posts queryClient.invalidateQueries({ queryKey: ['posts'] }); } });
const handleUpdate = (formData) => { updatePost({ id: postId, title: formData.get('title'), body: formData.get('body') }); };
return ( <form onSubmit={(e) => { e.preventDefault(); handleUpdate(new FormData(e.target)); }}> <input name="title" defaultValue={initialData?.title} placeholder="Título" /> <textarea name="body" defaultValue={initialData?.body} placeholder="Contenido" />
<button type="submit" disabled={isLoading}> {isLoading ? 'Actualizando...' : 'Actualizar Post'} </button>
{error && <div style={{ color: 'red' }}>Error: {error.message}</div>} </form> );}
Eliminar un Post
Section titled “Eliminar un Post”function DeletePost({ postId, onDeleted }) { const queryClient = useQueryClient();
const { mutate: deletePost, isLoading } = useMutation({ mutationFn: async (id) => { const response = await fetch(`/api/posts/${id}`, { method: 'DELETE', });
if (!response.ok) { throw new Error('Failed to delete post'); }
return { id }; // Devolver el ID para el callback },
onSuccess: (data) => { // Remover del cache queryClient.removeQueries({ queryKey: ['post', data.id] });
// Invalidar lista de posts queryClient.invalidateQueries({ queryKey: ['posts'] });
// Callback opcional onDeleted?.(data.id); } });
return ( <button onClick={() => { if (confirm('¿Estás seguro de eliminar este post?')) { deletePost(postId); } }} disabled={isLoading} style={{ backgroundColor: '#dc3545', color: 'white', border: 'none', padding: '8px 16px', borderRadius: '4px', cursor: isLoading ? 'not-allowed' : 'pointer' }} > {isLoading ? '🗑️ Eliminando...' : '🗑️ Eliminar'} </button> );}
⚡ Optimistic Updates
Section titled “⚡ Optimistic Updates”Actualiza la UI inmediatamente antes de confirmar con el servidor:
function OptimisticUpdatePost({ postId }) { const queryClient = useQueryClient();
const { mutate: updatePost, isLoading } = useMutation({ mutationFn: async ({ id, ...updateData }) => { // Simular delay de red await new Promise(resolve => setTimeout(resolve, 1000));
const response = await fetch(`/api/posts/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateData), });
if (!response.ok) { throw new Error('Update failed'); }
return response.json(); },
onMutate: async ({ id, ...newData }) => { // Cancelar refetches en chapter await queryClient.cancelQueries({ queryKey: ['post', id] });
// Snapshot del valor anterior const previousPost = queryClient.getQueryData(['post', id]);
// Optimistic update queryClient.setQueryData(['post', id], (old) => ({ ...old, ...newData, updatedAt: new Date().toISOString() }));
// Devolver contexto con datos anteriores return { previousPost }; },
onError: (err, { id }, context) => { // Rollback en caso de error queryClient.setQueryData(['post', id], context?.previousPost); },
onSettled: ({ id }) => { // Refetch para sincronizar con servidor queryClient.invalidateQueries({ queryKey: ['post', id] }); } });
const handleLike = () => { updatePost({ id: postId, likes: (previousLikes) => previousLikes + 1 }); };
return ( <button onClick={handleLike} disabled={isLoading}> 👍 {isLoading ? 'Actualizando...' : 'Me gusta'} </button> );}
🔄 mutateAsync para Control de Flujo
Section titled “🔄 mutateAsync para Control de Flujo”Cuando necesitas el resultado de la mutación para continuar:
function CreatePostWithRedirect() { const navigate = useNavigate(); const queryClient = useQueryClient();
const { mutateAsync: createPost, isLoading, error } = useMutation({ mutationFn: async (newPost) => { const response = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newPost), }); return response.json(); },
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); } });
const handleSubmit = async (e) => { e.preventDefault(); const formData = new FormData(e.target);
try { // Esperar a que la mutación complete const newPost = await createPost({ title: formData.get('title'), body: formData.get('body'), userId: 1 });
// Navegar al nuevo post después de crearlo navigate(`/posts/${newPost.id}`);
// Mostrar notificación de éxito toast.success('Post creado exitosamente!');
} catch (error) { // Manejar error específicamente toast.error(`Error: ${error.message}`); } };
return ( <form onSubmit={handleSubmit}> <input name="title" placeholder="Título" required /> <textarea name="body" placeholder="Contenido" required />
<button type="submit" disabled={isLoading}> {isLoading ? 'Creando...' : 'Crear y Ver Post'} </button>
{error && <div style={{ color: 'red' }}>Error: {error.message}</div>} </form> );}
📋 CRUD Completo
Section titled “📋 CRUD Completo”Ejemplo completo de operaciones CRUD:
import { useState } from 'react';import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// API functionsconst postsAPI = { getAll: () => fetch('/api/posts').then(res => res.json()), getById: (id) => fetch(`/api/posts/${id}`).then(res => res.json()), create: (data) => fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }).then(res => res.json()), update: ({ id, ...data }) => fetch(`/api/posts/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }).then(res => res.json()), delete: (id) => fetch(`/api/posts/${id}`, { method: 'DELETE' })};
function PostsManager() { const [editingPost, setEditingPost] = useState(null); const queryClient = useQueryClient();
// Query para obtener todos los posts const { data: posts = [], isLoading, error } = useQuery({ queryKey: ['posts'], queryFn: postsAPI.getAll });
// Mutation para crear post const createMutation = useMutation({ mutationFn: postsAPI.create, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); } });
// Mutation para actualizar post const updateMutation = useMutation({ mutationFn: postsAPI.update, onSuccess: (data) => { queryClient.setQueryData(['post', data.id], data); queryClient.invalidateQueries({ queryKey: ['posts'] }); setEditingPost(null); } });
// Mutation para eliminar post const deleteMutation = useMutation({ mutationFn: postsAPI.delete, onSuccess: (_, deletedId) => { queryClient.invalidateQueries({ queryKey: ['posts'] }); queryClient.removeQueries({ queryKey: ['post', deletedId] }); } });
const handleCreate = (e) => { e.preventDefault(); const formData = new FormData(e.target);
createMutation.mutate({ title: formData.get('title'), body: formData.get('body'), userId: 1 });
e.target.reset(); };
const handleUpdate = (e) => { e.preventDefault(); const formData = new FormData(e.target);
updateMutation.mutate({ id: editingPost.id, title: formData.get('title'), body: formData.get('body') }); };
const handleDelete = (id) => { if (confirm('¿Seguro que quieres eliminar este post?')) { deleteMutation.mutate(id); } };
if (isLoading) return <div>Cargando posts...</div>; if (error) return <div>Error: {error.message}</div>;
return ( <div style={{ padding: '20px' }}> <h1>Gestor de Posts</h1>
{/* Formulario de creación */} <div style={{ marginBottom: '30px', padding: '20px', border: '1px solid #ccc' }}> <h3>Crear Nuevo Post</h3> <form onSubmit={handleCreate}> <div style={{ margin: '10px 0' }}> <input name="title" placeholder="Título del post" required style={{ width: '100%', padding: '8px' }} /> </div> <div style={{ margin: '10px 0' }}> <textarea name="body" placeholder="Contenido del post" required rows={3} style={{ width: '100%', padding: '8px' }} /> </div> <button type="submit" disabled={createMutation.isLoading} style={{ padding: '10px 20px', backgroundColor: '#28a745', color: 'white', border: 'none', borderRadius: '4px' }} > {createMutation.isLoading ? 'Creando...' : 'Crear Post'} </button> </form>
{createMutation.error && ( <div style={{ color: 'red', marginTop: '10px' }}> Error: {createMutation.error.message} </div> )} </div>
{/* Lista de posts */} <div> <h3>Posts Existentes ({posts.length})</h3> {posts.map(post => ( <div key={post.id} style={{ margin: '15px 0', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', backgroundColor: '#f9f9f9' }} > {editingPost?.id === post.id ? ( // Formulario de edición <form onSubmit={handleUpdate}> <input name="title" defaultValue={post.title} style={{ width: '100%', padding: '8px', marginBottom: '10px' }} /> <textarea name="body" defaultValue={post.body} rows={3} style={{ width: '100%', padding: '8px', marginBottom: '10px' }} /> <div> <button type="submit" disabled={updateMutation.isLoading} style={{ padding: '8px 16px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', marginRight: '10px' }} > {updateMutation.isLoading ? 'Guardando...' : 'Guardar'} </button> <button type="button" onClick={() => setEditingPost(null)} style={{ padding: '8px 16px', backgroundColor: '#6c757d', color: 'white', border: 'none', borderRadius: '4px' }} > Cancelar </button> </div> </form> ) : ( // Vista normal del post <> <h4>{post.title}</h4> <p>{post.body}</p> <div> <button onClick={() => setEditingPost(post)} style={{ padding: '6px 12px', backgroundColor: '#ffc107', color: 'black', border: 'none', borderRadius: '4px', marginRight: '10px' }} > ✏️ Editar </button> <button onClick={() => handleDelete(post.id)} disabled={deleteMutation.isLoading} style={{ padding: '6px 12px', backgroundColor: '#dc3545', color: 'white', border: 'none', borderRadius: '4px' }} > {deleteMutation.isLoading ? '⏳' : '🗑️'} Eliminar </button> </div> </> )} </div> ))} </div> </div> );}
export default PostsManager;
⚙️ Configuración Avanzada de Mutaciones
Section titled “⚙️ Configuración Avanzada de Mutaciones”Global Defaults
Section titled “Global Defaults”// En la configuración del QueryClientconst queryClient = new QueryClient({ defaultOptions: { mutations: { retry: 1, retryDelay: 1000, onError: (error) => { // Manejo global de errores console.error('Mutation error:', error); toast.error(`Error: ${error.message}`); } } }});
Mutation Defaults por Tipo
Section titled “Mutation Defaults por Tipo”// Configurar defaults para tipos específicos de mutationsqueryClient.setMutationDefaults(['posts', 'create'], { mutationFn: (newPost) => postsAPI.create(newPost), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); }});
queryClient.setMutationDefaults(['posts', 'update'], { mutationFn: ({ id, ...data }) => postsAPI.update(id, data), onSuccess: (data) => { queryClient.setQueryData(['post', data.id], data); queryClient.invalidateQueries({ queryKey: ['posts'] }); }});
✅ Mejores Prácticas para useMutation
Section titled “✅ Mejores Prácticas para useMutation”- Invalidar queries relacionadas: Siempre actualiza el cache después de mutaciones
- Usa optimistic updates: Para mejor UX en operaciones comunes
- Maneja errores apropiadamente: Tanto en callbacks como en la UI
- Usa mutateAsync para control de flujo: Cuando necesites el resultado
- Implementa confirmaciones: Para operaciones destructivas como DELETE
- Proporciona feedback visual: Loading states y mensajes de éxito/error
- Considera rollback: En optimistic updates que pueden fallar
- Usa mutation keys: Para poder cancelar mutaciones específicas
- Valida datos: Antes de enviar al servidor
- Implementa retry logic: Para mutaciones que pueden fallar temporalmente
¡Excelente! Ahora sabes cómo modificar datos con useMutation
. Continuemos con useQueryClient
para control avanzado del cache.
Próximo paso: useQueryClient