Full Stack Developer

Khalid.dev

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

Muhammad Khalid Chouhan 4 min read 3 views
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 in mutationFn
  • ✅ onError on every mutation that a user triggers
  • ✅ mutate in handlers; mutateAsync only with try/catch
  • ✅ Invalidate lists and detail keys
  • ✅ Pass computed values as mutation variables, don't read fresh state inside mutationFn
  • ✅ Use variables for 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.