TanStack Query Mutations: Patterns I Use for Loading, Errors and Cache Updates

Fetching data with TanStack Query (React Query) clicks quickly: useQuery, a key, a function, done. Mutations, meaning creating, updating and deleting, are where most bugs hide. While rebuilding the admin panel of this portfolio I fixed several of those bugs in my own code. Here are the patterns I now use every time.
1. Show success only in onSuccess
This was a real bug in my project form:
// ❌ before
mutationFn: async (data) => {
toast.success("Project created!"); // runs BEFORE the request finishes
return createProject(data);
},
The toast appeared even when saving failed. Side effects that depend on the result belong in the callbacks:
// ✅ after
const createMutation = useMutation({
mutationFn: createProject,
onSuccess: () => {
toast.success("Project created");
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
onError: (error: Error) => toast.error(error.message || "Failed to create project"),
});
2. Prefer mutate over await mutateAsync in event handlers
mutateAsync returns a promise that rejects on error. If you await it in a submit handler without try/catch, every failed request becomes an unhandled promise rejection, and usually the user sees nothing.
// ❌ silent failures
const onSubmit = async (data) => {
await createMutation.mutateAsync(data);
closeForm();
};
// ✅ errors go to onError, success logic lives in onSuccess
const onSubmit = (data) => createMutation.mutate(data);
Use mutateAsync only when you actually need to chain promises, and then wrap it in try/catch.
3. Invalidate every query the change affects
After a mutation, React Query doesn't know which cached data is now stale. You tell it:
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] }); // the list
queryClient.invalidateQueries({ queryKey: ["project", project._id] }); // the detail page
},
Forgetting the detail key is a classic bug: the list updates, but the project page still shows the old title until a hard refresh. A simple rule: design your query keys so related data shares a prefix, then invalidate by prefix.
4. Use the server response when you already have it
If the API returns the updated document, you don't need another round trip. Write it straight into the cache:
const saveMutation = useMutation({
mutationFn: updateSocialLinks,
onSuccess: (res) => {
queryClient.setQueryData(["social"], res.data); // instant UI update, no refetch
},
});
5. Pass everything the mutation needs as variables
This one is subtle. In my form, a technology typed into an input (but not yet added as a chip) was meant to be included on submit:
// ❌ the mutationFn closes over OLD state
const onSubmit = (values) => {
addPendingTech(); // setState: doesn't update `technologies` yet
saveMutation.mutate(values); // mutationFn still sees the old array
};
React state updates are not synchronous, so the new chip was silently dropped. The fix is to compute the final value and pass it in as a variable:
// ✅
const onSubmit = (values) => {
const techList = withPendingTech(); // compute the final value now
saveMutation.mutate({ values, techList });
};
Treat mutationFn like a pure function of its variables, not of component state.
6. Per-item loading states with variables
When a list has a delete button on every row, you only want the clicked row to show a spinner. The mutation exposes the variables of the current call:
<DeleteButton
pending={deleteMutation.isPending && deleteMutation.variables === project._id}
onConfirm={() => deleteMutation.mutate(project._id)}
/>
No extra useState needed.
7. Placeholder data for UI that must never flash empty
My footer social icons come from the database, but they should never disappear while loading. placeholderData renders sensible defaults instantly and swaps in the real data when it arrives:
useQuery({
queryKey: ["social"],
queryFn: getSocialLinks,
placeholderData: DEFAULT_SOCIAL_LINKS,
staleTime: 5 * 60_000, // links rarely change, so don't refetch on every page
});
Unlike initialData, placeholder data isn't written to the cache, and isPlaceholderData tells you when you're still showing the fallback (useful for forms that shouldn't initialise from defaults).
Quick checklist
- ✅ Success toasts and cache updates in
onSuccess, never inmutationFn - ✅
onErroron every mutation that a user triggers - ✅
mutatein handlers;mutateAsynconly withtry/catch - ✅ Invalidate lists and detail keys
- ✅ Pass computed values as mutation variables, don't read fresh state inside
mutationFn - ✅ Use
variablesfor per-row pending states
None of these are advanced features. They're small habits, but together they're the difference between a UI that usually works and one that you can trust when the network, the server or the user does something unexpected.