Build

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 the slug off the validated input, resolves the org, and proves the caller is a member; the handler just reads context.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 every todo.list
  • orpc.todo.key() - every query under the todo router, 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

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

On this page