1. useMutation
Where useQuery is for reads that run automatically, useMutation
is for writes you trigger explicitly — creating a todo, updating a profile, deleting
a comment. It gives you a mutate function and status flags, with no
automatic execution on render.
import { useMutation } from '@tanstack/react-query';
function AddTodoForm() {
const { mutate, isPending, isError } = useMutation({
mutationFn: (text: string) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()),
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const text = new FormData(e.currentTarget).get('text') as string;
mutate(text);
}
return (
<form onSubmit={handleSubmit}>
<input name="text" />
<button disabled={isPending}>{isPending ? 'Adding...' : 'Add'}</button>
{isError && <p>Something went wrong.</p>}
</form>
);
}
mutationFn is the async function that performs the write.
mutate(text) calls it with whatever argument you pass, and
isPending/isError track its status — the same status-flag
shape as useQuery, applied to a write instead of a read.
2. Invalidating Queries
A successful mutation on the server doesn't automatically update anything already cached from Week 8's queries — the todos list query still holds whatever it fetched before the new todo existed. Invalidation tells TanStack Query "this cached data might be wrong now, refetch it":
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodoForm() {
const queryClient = useQueryClient(); // access to the shared cache
const { mutate, isPending } = useMutation({
mutationFn: (text: string) => createTodo(text),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); // refetch anything keyed ['todos', ...]
},
});
// ...
}
invalidateQueries marks every query whose key matches (or starts with)
['todos'] as stale and triggers a background refetch for any that are
currently in use — so the todos list re-renders with the new item shortly after the
mutation succeeds, with zero manual cache-editing on your part.
3. Optimistic Updates
Invalidation still means waiting for a refetch after the mutation resolves — for a fast action like toggling a todo's checkbox, that round-trip delay is noticeable and feels sluggish. An optimistic update updates the cache immediately, assuming success, and rolls back only if the mutation actually fails.
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => toggleTodoOnServer(id),
onMutate: async (id: number) => {
await queryClient.cancelQueries({ queryKey: ['todos'] }); // avoid a race with an in-flight refetch
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], old =>
old?.map(todo => (todo.id === id ? { ...todo, done: !todo.done } : todo))
);
return { previousTodos }; // saved for rollback in onError
},
onError: (_err, _id, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos); // roll back
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); // reconcile with the server either way
},
});
}
Four lifecycle callbacks work together here: onMutate runs before the
network request, snapshotting the current cache and applying the optimistic change;
onError restores that snapshot if the server rejects the mutation;
onSettled runs after either outcome and invalidates to reconcile the
cache with whatever the server actually ended up with. The checkbox flips instantly on
click — the network request happens invisibly behind that instant feedback.
Optimistic updates suit low-risk, easily-reversible actions (toggling, liking, reordering). For something consequential — placing an order, deleting an account — showing success before the server has actually confirmed it can mislead the user; a plain pending state with a real wait is often the more honest choice.
4. Loading/Error UI Patterns
A few small conventions make loading and error states feel considered rather than
bolted on. First: distinguish the very first load of a query from a background
refetch of already-cached data — isPending only reflects "no data yet at
all," while isFetching is also true during any background refetch.
function TodoList() {
const { data: todos, isPending, isFetching, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isPending) return <Skeleton />; // truly nothing to show yet
if (isError) return <p>Couldn't load todos.</p>;
return (
<div>
{isFetching && <span className="subtle-spinner" />} {/* refreshing existing data */}
<ul>{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>
</div>
);
}
This distinction matters for feel: replacing the whole list with a full-page spinner on every background refetch (like the window-refocus refetch from Week 8) is jarring; a small, unobtrusive indicator while the existing data stays visible reads as "quietly keeping this fresh" instead of "reloading."
5. Request Cancellation
If a query's inputs change before the previous request finishes — typing quickly in
a search box, navigating away from a page mid-fetch — the old request is wasted work.
TanStack Query passes an AbortSignal into queryFn, wired to
cancel automatically when a query becomes obsolete:
useQuery({
queryKey: ['search', query],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/search?q=${query}`, { signal });
return response.json();
},
});
Passing signal straight to fetch is enough — if the
query changes again before this request resolves (making this exact
query key obsolete), TanStack Query aborts the in-flight request via that signal
automatically. The browser stops the network request outright, rather than just
ignoring its result the way Week 2's hand-written ignore flag did —
genuinely canceling wasted work instead of merely discarding it after the fact.
6. Hands-on Exercise
Build a todo app with optimistic toggling and a cancellable search
Combine mutations, invalidation, optimistic updates, and cancellation in one app.
Requirements:
- Simulate a small backend: an in-memory todos array plus
fetchTodos,createTodo,toggleTodo, anddeleteTodofunctions with artificial delay (~300–500ms). - Build the todo list with
useQuery, an add form withuseMutation+invalidateQuerieson success, and a delete button per item, also using invalidation. - Make the checkbox toggle fully optimistic using the four-callback pattern from Section 3 — including a rollback path. Temporarily make
toggleTodorandomly fail 30% of the time and confirm the checkbox visually reverts on failure. - Add a search input above the list that filters todos server-side (simulate with your in-memory array) via a query keyed on
['todos', 'search', searchText], using thesignalpattern from Section 5. Confirm — via a console log inside the simulated fetch — that typing quickly cancels superseded requests instead of letting every keystroke's request complete.
To make your simulated fetch actually respect the AbortSignal, check signal.aborted inside the setTimeout callback (or listen for the signal's abort event) before resolving — a plain setTimeout with no signal handling will still "complete" even after the real fetch equivalent would have been cancelled.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why doesn't a successful useMutation call automatically update a useQuery list elsewhere in the app?
Why doesn't a successful useMutation call automatically update a useQuery list elsewhere in the app?
Mutations and queries are separate cache operations by design — TanStack Query has no way to know that a given mutation's result should affect a specific query's cached data unless you tell it to. That's exactly what invalidateQueries (or a manual setQueryData) is for: explicitly connecting a write to the reads it should affect.
Q2
In an optimistic update, what is the value returned from onMutate used for?
In an optimistic update, what is the value returned from onMutate used for?
It becomes the context argument passed to onError (and onSettled), most commonly used to carry a snapshot of the cache taken right before the optimistic change was applied. If the mutation fails, onError uses that snapshot to roll the cache back to exactly what it was before the optimistic update.
Q3
What's the practical difference between isPending and isFetching on a query?
What's the practical difference between isPending and isFetching on a query?
isPending is true only when there's no cached data at all yet — the very first load. isFetching is true any time a request is in flight, including a background refetch of data that's already cached and being shown. Using the right one lets you show a full loading skeleton only on first load, and a subtler indicator during background refreshes.
Q4
How is passing signal to fetch different from Week 2's hand-written ignore flag?
How is passing signal to fetch different from Week 2's hand-written ignore flag?
The ignore flag lets a stale request finish and simply discards its result — the network call still runs to completion, wasting bandwidth and server work. An AbortSignal passed to fetch actually terminates the in-flight request at the network level the moment it's superseded, which is real cancellation rather than after-the-fact filtering.