mutateAsync crashes the page — or nested onSuccess hell blocks a multi-step signup
Default to mutate and reach for mutateAsync only when you genuinely need a promise, because its rejections are yours to catch
What you'll build: a clear rule — default to mutate for fire-and-forget writes; reach for mutateAsync only when you need a Promise (chained APIs, Promise.all, RHF setError) — with try/catch so rejections never become unhandled.
The scenario
Crash path. A profile form uses await updateProfile.mutateAsync(values) inside RHF's onSubmit but the catch only handles 422. A 500 rejects the promise; nothing catches it → Unhandled Promise Rejection (and in some setups a redbox). Users see a broken submit with a console scream.
Hell path. Company signup needs createCompany → createAdmin(company.id) → sendWelcomeEmail. Devs nest mutate({ onSuccess: () => mutate({ onSuccess: … }) }) — unreadable, error paths diverge, partial success leaves orphan companies.
Why it escaped QA: happy-path 200s never reject; nested callbacks "work" in the demo script; RHF examples online use mutateAsync without showing failure branches.
Walkthrough
Stage 1 — Name the return types
mutate vs mutateAsync: mutate → void, errors via callbacks (Query handles the rejection). mutateAsync → Promise, you must catch.
Stage 2 — Reject nesting mutate for sequencing
Callback pyramids lose shared try/catch, complicate loading flags, and duplicate toast logic. If you need the result of mutation 1 for mutation 2, you want async/await.
Stage 3 — Pick the right API
Fire-and-forget (default):
const del = useMutation({ mutationFn: deletePost, onSuccess: () => { toast.success("Deleted"); queryClient.invalidateQueries({ queryKey: ["posts"] }); }, onError: () => toast.error("Delete failed"),});
<button type="button" onClick={() => del.mutate(postId)}>Delete</button>Sequenced / form integration:
const company = useMutation({ mutationFn: createCompany });const admin = useMutation({ mutationFn: createAdmin });
async function register(data: FormValues) { try { const co = await company.mutateAsync(data.company); await admin.mutateAsync({ ...data.admin, companyId: co.id }); toast.success("Registered"); } catch (e) { toast.error(e instanceof Error ? e.message : "Registration failed"); }}RHF field errors:
const onSubmit = handleSubmit(async (values, helpers) => { try { await profile.mutateAsync(values); } catch (e) { if (isAxiosError(e) && e.response?.status === 422) { helpers.setError("email", { message: "Email already used" }); return; } throw e; // or toast — but don't leave it unhandled }});Stage 4 — Harden + verify the loop
- One argument to
mutate/mutateAsync— pack{ id, body }. - Don't mix: if you
mutateAsync, don't also rely only on hook-levelonErrorwithout catch (both can run; still catch the await). - Partial multi-step: compensate (delete company) or use a server-side saga — client sequencing isn't a transaction.
Verify the loop. Force 500 on profile submit with mutateAsync: no unhandled rejection; user sees toast/field error. Run three-step register with step-2 failure: catch runs once; no nested callback spaghetti in the PR.
Variations
Promise.alluploads —await Promise.all(files.map((f) => upload.mutateAsync(f))).Promise.allSettled— continue after one failure; report which files failed.- mutate + per-call callbacks —
mutate(vars, { onSuccess })when one-off UI differs from hook defaults. - Server Action forms — prefer React 19 actions for form-shaped writes (double-submit); Query mutations for non-form cache writes.
- Idempotent retries — safe to
mutateAsyncagain only if the API is idempotent.
Trade-offs and common pitfalls
mutateAsyncwithouttry/catch— crash/unhandled rejection.- Nested
onSuccesschains — unmaintainable sequencing. - Assuming hook
onErrorprevents unhandled rejection on await — still catch awaits. - Multiple args to
mutate— only one variables arg. - Using
mutateAsync"everywhere for consistency" — loses the safe default. - Ignoring partial success in chains — orphan rows.
- Duplicating invalidate in await path and
onSettled— double fetch; pick one place. - Blocking UI without pending state — use
isPendingfrom the mutation(s). - Catch that swallows 422 and 500 identically — map status to UX.
- Testing only resolve paths — inject rejects in Vitest.
When NOT to use mutateAsync
If the UI only needs toast + invalidate, mutate is correct. Promises add footguns without leverage. Reach for mutateAsync when the caller must await.
See also
mutatevsmutateAsync- Forms at scale — RHF submit integration
- Upload can't be cancelled — mutation abort is separate
References
- TanStack Query — Mutations
Demo source
demos/data-fetching/mutate-async-unhandled-rejection/— reject paths + sequenced signup. (Demo host TBD)