Build

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 the slug, resolves the org, and proves the caller is a member; the handler just reads ctx.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 every todo.list
  • trpc.todo.pathFilter() - every query under the todo router, 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

  1. Fetching - Queries and caching
  2. Full walkthrough - Build a CRUD feature
  3. Error handling - tRPC error codes

On this page