Skip to content

useMutation

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.

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

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

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

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

Ejemplo completo de operaciones CRUD:

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// API functions
const 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”
// En la configuración del QueryClient
const 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}`);
}
}
}
});
// Configurar defaults para tipos específicos de mutations
queryClient.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'] });
}
});
  1. Invalidar queries relacionadas: Siempre actualiza el cache después de mutaciones
  2. Usa optimistic updates: Para mejor UX en operaciones comunes
  3. Maneja errores apropiadamente: Tanto en callbacks como en la UI
  4. Usa mutateAsync para control de flujo: Cuando necesites el resultado
  5. Implementa confirmaciones: Para operaciones destructivas como DELETE
  6. Proporciona feedback visual: Loading states y mensajes de éxito/error
  7. Considera rollback: En optimistic updates que pueden fallar
  8. Usa mutation keys: Para poder cancelar mutaciones específicas
  9. Valida datos: Antes de enviar al servidor
  10. 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