Data Mutations
Writes go through tRPC mutations: Zod validates, organizationProcedure proves org membership, TanStack Query refreshes the cache. The todo list and waitlist form are the two reference implementations.
Server Side
Every mutation is a procedure with a Zod input and an ownership check:
// packages/api/src/todo/todo-router.ts
create: organizationProcedure.input(createTodoInput).mutation(async ({ ctx, input }) => {
const [createdTodo] = await ctx.db
.insert(todo)
.values({ organizationId: ctx.organization.id, title: input.title })
.returning();
return { todo: createdTodo };
}),Rules the todo router demonstrates:
- Validate at the boundary - inputs are parsed by Zod before your code runs; invalid input never reaches the database
- Membership before the handler -
organizationProcedure(packages/api/src/trpc.ts) takes theslug, resolves the org, and proves the caller is a member; the handler just readsctx.organization.id - Scope every write - updates and deletes filter by
organizationId, not just row id, so members of one org can't touch another's rows .returning()+ explicit NOT_FOUND - if the scoped write matched nothing, throw instead of silently succeeding
Client Side: Two Styles
mutationOptions (the default)
The options proxy builds a typed useMutation config. Bare, like the waitlist form:
// apps/web/src/app/(marketing)/_components/waitlist-form.tsx
const trpc = useTRPC();
const joinWaitlist = useMutation(trpc.waitlist.join.mutationOptions());
joinWaitlist.mutateAsync({ email });Or with onError/onSuccess per call site - the todo list passes both:
// _components/todo-list.tsx
const trpc = useTRPC();
const queryClient = useQueryClient();
const onError = (error: { message: string }) => toast.error(error.message);
const invalidateTodos = () => queryClient.invalidateQueries(trpc.todo.list.queryFilter({ slug }));
const createTodo = useMutation(
trpc.todo.create.mutationOptions({ onError, onSuccess: invalidateTodos }),
);
await createTodo.mutateAsync({ slug, title: trimmed });The raw client (manual control)
useTRPCClient() returns the bare client - for calling a procedure outside React Query, or wiring a custom mutationFn yourself. The reference components don't need it; reach for it only when mutationOptions can't express what you want:
const trpcClient = useTRPCClient();
await trpcClient.todo.create.mutate({ slug, title });Input types come from the router, not hand-written:
import type { RouterInputs } from "@repo/api";
type CreateTodoInput = RouterInputs["todo"]["create"];Cache Invalidation
Each mutation invalidates the queries it changed, in its own onSuccess. The todo list refetches its org's list after every write:
// _components/todo-list.tsx
// Refetch this org's list after a write. Each mutation refreshes only what it
// touched — there's no global invalidate-everything net behind it.
const invalidateTodos = () => queryClient.invalidateQueries(trpc.todo.list.queryFilter({ slug }));
const createTodo = useMutation(
trpc.todo.create.mutationOptions({ onError, onSuccess: invalidateTodos }),
);
const updateTodo = useMutation(
trpc.todo.update.mutationOptions({ onError, onSuccess: invalidateTodos }),
);
const deleteTodo = useMutation(
trpc.todo.delete.mutationOptions({ onError, onSuccess: invalidateTodos }),
);Two filter helpers on the options proxy, both accepted by queryClient.invalidateQueries(...):
trpc.todo.list.queryFilter({ slug })- one query, scoped to its input; drop the input to match everytodo.listtrpc.todo.pathFilter()- every query under thetodorouter, for writes that touch several lists
The same pattern covers mutations that wrap a better-auth call instead of a tRPC procedure:
onSuccess: () => queryClient.invalidateQueries(trpc.organization.get.queryFilter({ slug })),If the UI doesn't reflect a write, the mutation is missing an invalidation - add one to its onSuccess.
Pending States
TanStack Query tracks in-flight state; the UI just reads it:
// Disable the form while creating
<Button type="submit" loading={createTodo.isPending}>
Add
</Button>;
// Per-row spinner: match the in-flight variables to this row
const isDeleting = deleteTodo.isPending && deleteTodo.variables.id === todo.id;mutation.variables holds the input of the in-flight call - that's how one useMutation instance drives per-row states in a list.
Feedback and Confirmation
Patterns from the reference components:
// Toast on lifecycle (waitlist-form.tsx)
toast.promise(joinWaitlist.mutateAsync({ email }), {
loading: "Submitting...",
success: "Waitlist joined!",
error: "Failed to join waitlist",
});
// Confirm destructive actions (todo-list.tsx)
alertDialog.open(`Delete "${todo.title}"?`, {
description: "This action cannot be undone.",
action: { label: "Delete", onClick: async () => deleteTodo.mutateAsync({ slug, id: todo.id }) },
cancel: { label: "Cancel" },
});toast and alertDialog come from @repo/ui/components/sonner and @repo/ui/components/alert-dialog. For error toasts, prefer the mutation's onError (shown above) so every call site gets it.
Form Validation
Reuse the server's Zod schema on the client - one source of truth, errors before the round-trip:
// waitlist-form.tsx
import { joinWaitlistInput } from "@repo/api/waitlist/waitlist-schema";
const form = useForm({
validators: { onSubmit: joinWaitlistInput },
// ...
});The server still validates - client-side is UX, server-side is the guarantee.
Idempotent Writes
For writes that may repeat (signups, upserts), absorb the conflict instead of erroring:
// packages/api/src/waitlist/waitlist-router.ts
await ctx.db
.insert(waitlist)
.values({ ...input })
.onConflictDoNothing({ target: waitlist.email });Next Steps
- Fetching - Queries and caching
- Full walkthrough - Build a CRUD feature
- Error handling - tRPC error codes