Navigate
Search topics, hooks, and fundamentals
Reading data is one thing. What about creating, updating, and deleting?
Manual POST with try-catch, loading flags, and no cache sync. Each mutation is a pile of boilerplate.
One hook that tracks the full mutation lifecycle — idle, pending, success, error — with callbacks at every step.
One hook. Status tracking, callbacks, error handling — all built in.
const mutation = useMutation({
mutationFn: (title: string) =>
fetch("/api/posts", {
method: "POST",
body: JSON.stringify({ title }),
}).then(res => res.json()),
onSuccess: (data) => {
console.log("Created!", data);
// queryClient.invalidateQueries({ queryKey: ["posts"] });
},
onError: (error) => {
console.error("Failed:", error);
},
});
// Usage: mutation.mutate("My new post");
// Built-in: isPending, isError, isSuccess, data, erroruseMutation handles the full lifecycle: idle → pending → success/error, with callbacks at every step.
useMutation pairs with useQuery for full data management — CRUD operations, cache updates, and optimistic UI.
Create and delete update the cache directly — no refetch needed!
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (title: string) =>
fetch("/api/posts", { method: "POST", body: JSON.stringify({ title }) })
.then(r => r.json()),
onSuccess: (newPost) => {
// Update cache directly — no refetch needed!
queryClient.setQueryData(["my-posts"], (old: Post[]) =>
[...old, newPost]
);
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) =>
fetch(`/api/posts/${id}`, { method: "DELETE" }),
onSuccess: (_, deletedId) => {
queryClient.setQueryData(["my-posts"], (old: Post[]) =>
old.filter(p => p.id !== deletedId)
);
},
});Add a todo — it appears instantly. If the server fails (20% chance), it rolls back.
const addTodo = useMutation({
mutationFn: (text: string) => createTodoOnServer(text),
// Optimistic update: change UI BEFORE server responds
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData(["todos"]);
// Add the todo immediately (optimistic)
queryClient.setQueryData(["todos"], (old) => [
...old,
{ id: Date.now(), text, done: false },
]);
return { previous }; // save for rollback
},
// If error: roll back to previous state
onError: (err, text, context) => {
queryClient.setQueryData(["todos"], context.previous);
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.
const mutation = useMutation({
mutationFn: (newTodo) => axios.post("/todos", newTodo),
// Nothing happens after success — cache is stale!
});
function handleSubmit(data) {
mutation.mutate(data);
// List still shows old data until manual refresh
}const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Missing: cancelQueries, snapshot, context return
queryClient.setQueryData(["todos"], (old) => {
old.push(newTodo); // mutates in place!
return old; // same reference — React won't re-render
});
},
// Missing: onError rollback
// Missing: onSettled invalidation
});const mutation = useMutation({
mutationFn: (id) => {
axios.delete(`/todos/${id}`); // missing return!
},
onSuccess: () => {
// Fires IMMEDIATELY — server hasn't deleted yet
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});