Mutations & Cache Updates
Reading data is only half of working with server state — eventually you need to change it. A mutation is any request that creates, updates, or deletes data on the server (typically POST, PUT, PATCH, or DELETE). The hard part is not sending the request; it is keeping your cached, already-rendered data in sync afterward so the UI reflects the new reality. TanStack Query’s useMutation hook handles the lifecycle of a write and gives you precise hooks for updating the cache, including optimistic updates with automatic rollback.
The useMutation hook
Unlike useQuery, which runs automatically, a mutation runs only when you call mutate. You give useMutation a mutationFn that performs the write and returns a promise; the hook returns that trigger plus status flags for building loading and error UI.
import { useMutation } from "@tanstack/react-query";
async function createPost(newPost) {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}
function NewPostButton() {
const { mutate, isPending, isError, error, isSuccess } = useMutation({
mutationFn: createPost,
});
return (
<div>
<button
disabled={isPending}
onClick={() => mutate({ title: "Hello", body: "World", userId: 1 })}
>
{isPending ? "Saving…" : "Create post"}
</button>
{isError && <p role="alert">Error: {error.message}</p>}
{isSuccess && <p>Post created!</p>}
</div>
);
}
Output:
Create post (idle)
Saving… (while the request is in flight)
Post created! (on success)
The mutate function is fire-and-forget. If you need to await the result, use mutateAsync, which returns the promise so you can try/catch around it.
Invalidating vs. updating the cache
After a successful write, the data you previously fetched is stale. You have two ways to reconcile it.
| Strategy | How it works | When to use |
|---|---|---|
| Invalidate | Mark matching queries stale; they refetch from the server | Default choice — guarantees correctness, costs one round trip |
| Update directly | Write the new value into the cache with setQueryData | Avoid an extra fetch when the server response already contains the final data |
Invalidation is the simplest and safest. You let the server remain the source of truth:
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
If the response already contains the authoritative record, you can splice it straight into the cache and skip the refetch:
onSuccess: (created) => {
queryClient.setQueryData(["posts"], (old = []) => [...old, created]);
},
Tip: When in doubt, invalidate. Direct cache writes are faster but force you to replicate server logic (sorting, derived fields, IDs) on the client — get it wrong and the UI silently drifts from the database.
Optimistic updates with rollback
For snappy UIs you can update the cache before the server responds, then roll back if it fails. useMutation exposes a lifecycle built exactly for this: onMutate runs first (apply the optimistic change and snapshot the old state), onError rolls back, and onSettled reconciles by refetching.
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo) =>
fetch(`https://jsonplaceholder.typicode.com/todos/${todo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !todo.completed }),
}).then((r) => {
if (!r.ok) throw new Error("Update failed");
return r.json();
}),
onMutate: async (todo) => {
// 1. Cancel in-flight refetches so they can't overwrite our optimistic value
await queryClient.cancelQueries({ queryKey: ["todos"] });
// 2. Snapshot the current cache for rollback
const previous = queryClient.getQueryData(["todos"]);
// 3. Optimistically apply the change
queryClient.setQueryData(["todos"], (old = []) =>
old.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
);
// 4. Pass the snapshot to onError via context
return { previous };
},
onError: (_err, _todo, context) => {
// Restore the snapshot taken in onMutate
queryClient.setQueryData(["todos"], context.previous);
},
onSettled: () => {
// Always resync with the server, whether we succeeded or failed
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
The four steps matter in order: cancelling queries prevents a slow background fetch from clobbering your optimistic state, and the snapshot returned from onMutate becomes the context argument every other callback receives.
A worked example: a todo list
Putting it together, a single component can render the list, fire the mutation, and reflect the optimistic toggle instantly.
import { useQuery } from "@tanstack/react-query";
function TodoList() {
const { data: todos, isPending } = useQuery({
queryKey: ["todos"],
queryFn: () =>
fetch("https://jsonplaceholder.typicode.com/todos?_limit=5").then((r) =>
r.json()
),
});
const toggle = useToggleTodo();
if (isPending) return <p>Loading…</p>;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
disabled={toggle.isPending}
onChange={() => toggle.mutate(todo)}
/>
{todo.title}
</label>
</li>
))}
</ul>
);
}
Because onMutate updates the cache synchronously, the checkbox flips the instant it is clicked — no spinner, no waiting on the network. If the request fails, onError snaps it back.
Best Practices
- Reach for invalidation by default; only do optimistic updates for high-frequency, low-risk interactions like toggles, likes, and reordering.
- Always
cancelQueriesinsideonMutateso a pending background refetch cannot overwrite your optimistic value. - Return a rollback snapshot from
onMutateand restore it inonError— never assume a mutation succeeds. - Use
onSettledto invalidate, guaranteeing the cache matches the server regardless of success or failure. - Drive button state from
isPending, and disable triggers while a mutation is in flight to prevent duplicate submissions. - Prefer
setQueryDataover a refetch only when the server response is the authoritative final record. - Extract mutations into custom hooks (
useCreatePost,useToggleTodo) to keep components declarative and reusable.