Build

Data Mutations

Writes go through tRPC mutations: Zod validates, the procedure checks org access, 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: protectedProcedure.input(createTodoInput).mutation(async ({ ctx, input }) => {
  const organization = await ensureOrganizationAccess(ctx, input.slug);

  const [createdTodo] = await ctx.db
    .insert(todo)
    .values({ organizationId: 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
  • 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 (simple case)

When you just need to fire the mutation, the options proxy is the shortest path - the waitlist form uses it:

// apps/web/src/app/(marketing)/_components/waitlist-form.tsx
const trpc = useTRPC();
const joinWaitlist = useMutation(trpc.waitlist.join.mutationOptions());

joinWaitlist.mutateAsync({ email });

mutationFn with the raw client (full control)

When you want custom onError/onSuccess per call site, the todo list uses the bare client:

// _components/todo-list.tsx
const trpcClient = useTRPCClient();

const createTodo = useMutation({
  mutationFn: (input: CreateTodoInput) => trpcClient.todo.create.mutate(input),
  onError: (error) => toast.error(error.message),
});

await createTodo.mutateAsync({ slug, title: trimmed });

Input types come from the router, not hand-written:

import type { RouterInputs } from "@repo/api";

type CreateTodoInput = RouterInputs["todo"]["create"];

Cache Invalidation

You don't write invalidation code for the common case. The mutation cache invalidates everything after any successful mutation:

// apps/web/src/trpc/query-client.ts
mutationCache: new MutationCache({
  onSuccess: async (_data, _variables, _result, _mutation, context) => {
    await context.client.invalidateQueries();
  },
}),

Blunt, but always correct - every list refetches with fresh data. Local callbacks compose with the cache callback:

const createTodo = useMutation({
  mutationFn: (input: CreateTodoInput) => trpcClient.todo.create.mutate(input),
  onSuccess: () => toast.success("Todo created"),
});

To narrow a hot path, replace the global cache policy and add targeted invalidation to each mutation.

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