Data Mutations
Writes go through oRPC procedures: 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(createTodoInput).handler(async ({ context, input }) => {
const [createdTodo] = await context.db
.insert(todo)
.values({ organizationId: context.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/orpc.ts) reads theslugoff the validated input, resolves the org, and proves the caller is a member; the handler just readscontext.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 joinWaitlist = useMutation(orpc.waitlist.join.mutationOptions());
joinWaitlist.mutateAsync({ email });Or with onError/onSuccess per call site - the todo list passes both:
// _components/todo-list.tsx
const queryClient = useQueryClient();
const onError = (error: { message: string }) => toast.error(error.message);
const invalidateTodos = () =>
queryClient.invalidateQueries({ queryKey: orpc.todo.list.key({ input: { slug } }) });
const createTodo = useMutation(
orpc.todo.create.mutationOptions({ onError, onSuccess: invalidateTodos }),
);
createTodo.mutate({ slug, title: trimmed });The raw client (manual control)
.call invokes the procedure directly - for calling it 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:
await orpc.todo.create.call({ slug, title });Input types come from the router, not hand-written:
import type { RouterInputs } from "@repo/api";
type CreateTodoInput = RouterInputs["todo"]["create"];Better Auth Mutations
Better-auth returns { data, error } by default. TanStack Query treats a resolved promise as success, so its mutation functions must request throwing errors:
const removeMember = useMutation({
mutationFn: (memberId: string) =>
authClient.organization.removeMember({
memberIdOrEmail: memberId,
fetchOptions: { throw: true },
}),
onSuccess: () => invalidateOrganization(queryClient, slug),
onError: (error) => toast.error(error.message),
});Use mutate for event handlers. Use mutateAsync only when a caller awaits completion and handles rejection. The shared confirmation dialog awaits actions, keeps failures open for retry, and leaves error reporting to each mutation.
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
const invalidateTodos = () =>
queryClient.invalidateQueries({ queryKey: orpc.todo.list.key({ input: { slug } }) });
const createTodo = useMutation(
orpc.todo.create.mutationOptions({ onError, onSuccess: invalidateTodos }),
);
const updateTodo = useMutation(
orpc.todo.update.mutationOptions({ onError, onSuccess: invalidateTodos }),
);
const deleteTodo = useMutation(
orpc.todo.delete.mutationOptions({ onError, onSuccess: invalidateTodos }),
);.key() builds a partial-matching key at any depth, which is what invalidation wants:
orpc.todo.list.key({ input: { slug } })- one query, scoped to its input; drop the input to match everytodo.listorpc.todo.key()- every query under thetodorouter, for writes that touch several lists
(.queryKey() is the full-matching counterpart - use it for getQueryData / setQueryData, not invalidation.)
Mutations that wrap a better-auth call instead of an oRPC procedure refresh the cache the same way, with one rule on top: a query with more than one writer exports its own helpers next to the hook, so the key shape has one owner. Inline .key() is for a query with a single consumer, like the todo list above. organization.get is written by the members table, the invite form, the invitations table, and the settings form, so they all go through its helpers:
import {
invalidateOrganization,
removeOrganization,
} from "@/app/(dashboard)/dashboard/[slug]/_components/use-organization";
onSuccess: () => invalidateOrganization(queryClient, slug),Use removeOrganization instead when the write changes the slug: invalidating would refetch the still-mounted query into NOT_FOUND and its retry backoff, while the new route prefetches the fresh key anyway.
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 () => {
await 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 = useAppForm({
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 context.db
.insert(waitlist)
.values({ ...input })
.onConflictDoNothing({ target: waitlist.email });Next Steps
- Fetching - Queries and caching
- Full walkthrough - Build a CRUD feature
- Error handling - oRPC error codes