Next mutations
Write data in a Next.js App Router app the canonical way — a server action that throws on failure, triggered from the client with useTransition (simple) or useMutation (optimistic), forms via TanStack Form + Zod, feedback via toast (Sonner). Use when the user adds a create/update/delete, a form + submit, says "mutate", "submit form", "optimistic update", "toast on success", "invalidate cache after a write", "rollback on error", or asks how to handle pending state or form validation. Pairs with `next-queries` for reads + cache tags.From its SKILL.md
npx -y skills add sanctuarynode/skills --skill next-mutationsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
10.5 KB, ~2.5k tokens by cl100k_base, as published. Nobody here has run it
Next.js + TanStack Query: mutations & forms
Every write is two parts: a server action that performs the write and a client trigger that gives feedback via toast.
User submits
└─▶ TanStack Form + Zod validates onSubmit
├─ invalid ─▶ FieldError per field (no network call)
└─ valid ───▶ pending (useTransition or useMutation)
└─▶ server action createThing(scope, body)
└─▶ typed API client (Eden) → backend
├─ error ─▶ THROW human-readable message ─▶ catch → toast.error(message)
└─ ok ────▶ updateTag(scope:resource) (invalidate cached reads)
└─▶ toast.success → onSuccess?.() (close dialog)
Read it as: the form validates locally first, so an invalid submit never hits the network. A valid submit runs the server action, which throws a human message on failure (that string becomes the toast); on success it updateTags the read cache and the client toasts + closes.
Server action — throw on failure
Mutation actions throw (they don't return { error } like fetching actions). The thrown message is the toast text. Keep debug context server-side; never send it to the client.
// actions/things.ts
"use server";
import { updateTag } from "next/cache";
import { api } from "@/lib/api";
import { log } from "@/lib/log";
import type { CreateThing } from "@/lib/schema";
export async function createThing(orgSlug: string, body: CreateThing) {
const { data, error } = await api.things.post(body);
if (error) {
log.error({ action: "createThing", scope: orgSlug, error }); // internal only
throw new Error("Failed to create thing. Please try again."); // → toast text
}
updateTag(`${orgSlug}:things`); // invalidate the read cache (see next-queries)
return data;
}
Simple trigger — useTransition
For one-off mutations with no cached list to update, use React 19's useTransition for the pending flag (no provider, no useState).
"use client";
import { useTransition } from "react";
import { toast } from "sonner";
import { createThing } from "@/actions/things";
export function CreateThingButton({ orgSlug }: { orgSlug: string }) {
const [isPending, startTransition] = useTransition();
function handleCreate() {
startTransition(async () => {
try {
await createThing(orgSlug, { name: "New Thing" });
toast.success("Thing created");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Something went wrong");
}
});
}
return (
<Button onClick={handleCreate} disabled={isPending}>
{isPending ? "Saving…" : "Create"}
</Button>
);
}
Never useState for isPending — useTransition gives you the pending state for free.
Form — TanStack Form + Zod
Every form validates with Zod via TanStack Form; submit inside startTransition.
"use client";
import { useTransition } from "react";
import { useForm } from "@tanstack/react-form";
import { toast } from "sonner";
import { createThing } from "@/actions/things";
import { createThingSchema } from "@/lib/schema";
export function CreateThingForm({
orgSlug,
onSuccess,
}: {
orgSlug: string;
onSuccess?: () => void;
}) {
const [isPending, startTransition] = useTransition();
const form = useForm({
defaultValues: { name: "" },
validators: { onSubmit: createThingSchema },
onSubmit: ({ value }) => {
startTransition(async () => {
try {
await createThing(orgSlug, value);
toast.success("Thing created");
onSuccess?.();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Something went wrong");
}
});
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="flex flex-col gap-4"
>
<form.Field name="name">
{(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
<Button type="submit" disabled={isPending}>
{isPending ? "Saving…" : "Save"}
</Button>
</form>
);
}
Toast conventions (Sonner)
import { toast } from "sonner";
toast.loading("Saving…", { id: "op" }); // spinner
toast.success("Saved", { id: "op" }); // replace the same toast
toast.error("Failed to save", { id: "op" }); // replace with error
toast.error("Failed to create", { description: err.message }); // supplementary detail
Pass an id to update/replace a loading toast in place.
Optimistic updates — useMutation
When a mutation touches a cached list query, prefer useMutation with onMutate/onError/onSettled over useTransition. The row appears (and the dialog closes) before the network finishes; on failure you roll back to the pre-mutation snapshot.
mutation.mutate(value)
└─▶ onMutate (before network)
└─▶ cancelQueries stop in-flight refetch
└─▶ getQueriesData snapshot ALL page/limit/filter variants
└─▶ write optimistic change (Insert | Patch | Remove)
└─▶ return { snapshots } as rollback context
server result
├─ error ───▶ onError restore every snapshot + toast.error
└─ success ─▶ onSuccess toast + form.reset + close
onSettled (either way) ─▶ invalidateQueries refetch authoritative data
import { useMutation, useQueryClient } from "@tanstack/react-query";
const queryClient = useQueryClient();
const KEY = ["things"] as const;
const mutation = useMutation({
mutationFn: (value: ThingValues) => createThing(orgSlug, value),
onMutate: async (value) => {
await queryClient.cancelQueries({ queryKey: KEY }); // 1. stop competing refetch
const snapshots = queryClient.getQueriesData({ queryKey: KEY }); // 2. snapshot EVERY variant
const optimisticRow = { id: `optimistic-${value.name}`, ...value }; // 3. write the change
for (const [key, data] of snapshots) {
if (!data) continue;
queryClient.setQueryData(key, {
...data,
data: [optimisticRow, ...data.data],
pagination: { ...data.pagination, totalItems: data.pagination.totalItems + 1 },
});
}
return { snapshots }; // 4. rollback context
},
onError: (error, _value, ctx) => {
ctx?.snapshots.forEach(([key, data]) => queryClient.setQueryData(key, data));
toast.error(error instanceof Error ? error.message : "Something went wrong");
},
onSuccess: () => {
toast.success("Saved");
form.reset();
onOpenChange(false);
},
onSettled: () => void queryClient.invalidateQueries({ queryKey: KEY }),
});
Rules:
- The fake
id(optimistic-…) just keeps the row unique for React's key —onSettled's refetch replaces it with the real server id. - Use
getQueriesData(plural) — a paginated list has many cache entries (differentpage/limit/filter suffixes under one key prefix). Snapshot and write all of them so the change shows whatever page/filter the user is on, and rollback restores every one. (For a single non-paginated query, use singulargetQueryData/setQueryData.) onSettledruns after success and error, so invalidation always happens — don't also call it fromonSuccess.mutation.isPendingis the button's pending flag. Don't combine withuseTransition, and don't useuseState— pickuseMutation's flag.- Match a row by its stable key — usually
id, but match whatever the model uses.
The three list shapes
Same onMutate/onError/onSettled skeleton; only the cache write differs.
// Insert (create) — prepend + bump total
data: [optimisticRow, ...prev.data],
pagination: { ...prev.pagination, totalItems: prev.pagination.totalItems + 1 },
// Patch (edit / toggle / change-status) — map the matching row, total unchanged
data: prev.data.map((r) => (r.id === target.id ? { ...r, ...changed } : r)),
// Remove (delete / revoke) — filter out + decrement (clamp at 0)
const next = prev.data.filter((r) => r.id !== target.id);
pagination: { ...prev.pagination, totalItems: Math.max(0, prev.pagination.totalItems - (prev.data.length - next.length)) },
Toggle variant — authoritative onSuccess, no refetch
When the server action returns the full new object (e.g. a settings toggle echoing the whole record), skip the onSettled invalidate and write the returned object in onSuccess — it's already the source of truth, so an extra GET is wasted. Still do the optimistic onMutate + onError rollback.
onSuccess: (next) => queryClient.setQueryData(KEY, next),
// (no onSettled invalidate)
When NOT to use optimistic updates
- Single-record edits where the form is the data (a profile panel) — no list to roll back; just refetch/echo on success.
- Server-derived values you can't guess — e.g. create-api-key, where the server mints the id/prefix/one-time secret the dialog must show. Invalidate on success instead.
- Provider-level switches (locale/theme) — these re-render from a provider, not a cached list.
- Mutations that may take >2s — show a real loading state instead of faking instant success.
Destructive actions
Wrap delete/revoke behind a confirmation dialog before calling the server action. Then apply the Remove shape above (or a plain invalidate if the list isn't optimistic).
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.