Mutation
- Category: Points
A mutation pairs an input schema with a loader that does the write. It's a real
HTTP endpoint (its own POST path, in the OpenAPI spec) and, at the same time,
a thin wrapper over a classic
TanStack (react-query) mutation
— declared once, called anywhere by importing it directly.
import { root } from '@/lib/root'
import { authorizedOnlyPlugin } from '@/lib/auth'
import { z } from 'zod'
export const ideaCreateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin) // brings ctx.me — the current user
.input(z.object({ title: z.string().min(1), content: z.string().min(1) }))
.loader(async ({ ctx, input: { title, content } }) => {
// cut from the client bundle — this body and its imports never ship to the browser
const idea = await prisma.idea.create({
data: { title, content, authorId: ctx.me.user.id },
})
return { idea }
})
.mutation()// anywhere in a component:
const mutation = ideaCreateMutation.useMutation()
await mutation.mutateAsync({ title, content })The first argument to every mutation call is the input — that's what gets validated, sent to the loader, and used to find a specific call in the mutation cache.
Declaring a mutation
Open with .mutation(), declare input and a loader, close with
.mutation(options?):
export const ideaCreateMutation = root.lets
.mutation() // open
.input(z.object({ title: z.string().min(1) }))
.loader(async ({ input }) => ({ idea: await createIdea(input) }))
.mutation() // closeSee points for the .lets notation.
The two .mutation() calls are not the same method: the first opens a
mutation-stage point, the second closes it into a ready mutation. You must
add a .loader (or .clientLoader) before closing — calling
.mutation() with no loader is a type error:
root.lets.mutation().input(/* ... */).mutation()
// type error: Point has no loaders. Please add .loader()
// or .clientLoader() before calling .mutation()The closing .mutation(options?) closer is server-and-client — it's not cut
from either bundle, kept in both (isomorphic), because the options carry
react-query defaults the browser needs.
The loader is the mutation body
The .loader is the function that runs when the mutation fires; its
return value becomes the mutation's data:
.loader(async ({ input }) => {
const idea = await prisma.idea.create({ data: input })
return { idea } // => mutation.data is { idea }
})The server .loader is server-only — cut from the client bundle: its body
and the imports it uses are removed, so it never ships to the browser. Stays in
the server build (it runs on the server).
A mutation can have a server loader, a client loader, or both:
// server loader → cut from the client bundle (body + imports gone); the mutation is an HTTP endpoint
.loader(async ({ input }) => ({ idea: await prisma.idea.create({ data: input }) }))
// clientLoader only → cut from the server bundle (body + imports gone); no server request at all
.clientLoader(async ({ input }) => ({ ok: await callSomeBrowserApi(input) })).clientLoader is client-only — cut from the server bundle: body and its
imports removed (regardless of SSR), so it never lands in the server build. Kept
in the client build (it runs in the browser).
With a server loader, calling the mutation sends one request to the server; with a client-only loader, nothing leaves the browser. See Loader for the full callback surface and how server code is removed from the client build.
Input and validation
.input(schema) takes any Standard Schema — zod, valibot,
arktype, and others — or a custom validate function:
.input(z.object({ title: z.string().min(1), content: z.string().min(1) }))For a client loader, use .clientInput (client side) or .sharedInput (both
sides). Input schemas merge down the chain: a parent — a base or
plugin — can declare part of the input and the mutation adds the rest.
.input is server-only — cut from the client bundle: the server validation
schema and the imports it uses are removed, so it never ships to the browser.
.clientInput is client-only — cut from the server bundle: body and its
imports removed. .sharedInput is server-and-client — not cut from either
bundle, kept in both (isomorphic), because the same schema validates on the
server and in the browser.
A mutation uses .input (plus .clientInput / .sharedInput) — not
.params, .search, or .body; those are for pages and
actions and are a type error on a mutation:
root.lets.mutation().params(/* ... */)
// type error: For "mutation" not allowed "params" schema.
// Only "input" are allowed.A mutation with no input schema takes an optional input — mutation.mutate()
with no argument works. File inputs work too: a z.instanceof(File) field is
sent as multipart and the loader receives a real File. Full schema mechanics
live in Validation; file handling in File upload.
Using a mutation
As a hook. useMutation returns the standard TanStack UseMutationResult —
.mutate, .mutateAsync, .data, .isPending, .error, and the rest:
const mutation = ideaCreateMutation.useMutation()
// input is the first argument; per-call options follow it
await mutation.mutateAsync(
{ title, content },
{
onSuccess: async ({ idea }) => {
await navigate('ideaView', { id: idea.id })
},
},
)
mutation.isPending // disable the submit button while it runsImperatively, no hook. fetchMutation runs the mutation outside a React
render and resolves to the loader's output. Input is the first argument:
const { idea } = await ideaUpdateMutation.fetchMutation({ id, title, content })
await navigate('ideaView', { id: idea.id })Pass { queryClient } in the trailing options to target a specific client;
otherwise the global one is used.
react-query options
The closing .mutation({...}) takes standard TanStack useMutation options —
everything except mutationFn and mutationKey, which Point0 supplies.
They become the mutation's defaults and are the natural place to update other
caches on success:
export const ideaCreateMutation = root.lets
.mutation()
.input(ideaCreateSchema)
.loader(async ({ input }) => ({ idea: await createIdea(input) }))
.mutation({
onSuccess: async ({ idea }) => {
// seed the view query's cache so the next page reads it instantly
ideaViewQuery.setQueryData({ id: idea.id }, { idea })
},
})onSuccess here receives the loader's output, typed; onError, onSettled,
onMutate, retry, gcTime, and the rest are all available and typed against
the real output and input.
Per-call options on useMutation / mutateAsync / fetchMutation merge
with these defaults rather than replacing them — the callbacks chain, so both an
onSuccess on .mutation({...}) and an onSuccess on mutateAsync(...) run,
in order.
Defaults from up the chain
Set mutation defaults once on the root, a base, or a
plugin with .mutationOptions(...), and they apply to every mutation
beneath. There is no .mutationOptions() on the mutation itself — per-mutation
options go through the closing .mutation({...}). Both .mutationOptions and
the closing .mutation({...}) are server-and-client — not cut from either
bundle, kept in both (isomorphic), because they hold react-query defaults the
browser needs. Resolution is lowest-to-highest:
- root/base/plugin
.mutationOptions(...) - the mutation's own closing
.mutation({...}) - the call-site options on
useMutation/mutateAsync/fetchMutation
Callbacks (onMutate / onSuccess / onError / onSettled) at every level
run in order rather than overwriting each other.
Authorization
.ctx is server-only — cut from the client bundle: its body and the imports
it uses are removed, so it never ships to the browser. It runs only when the
mutation runs its loader — which a mutation always does, so a .use(plugin)
that resolves the current user into ctx lets the loader gate the write
server-side:
export const ideaUpdateMutation = root.lets
.mutation()
.use(authorizedOnlyPlugin) // ctx.me — resolved server-side
.input(ideaUpdateMutationSchema)
.loader(async ({ ctx, input: { sn, title, content } }) => {
const existing = await prisma.idea.findUniqueOrThrow({
select: { authorId: true },
where: { sn },
})
if (existing.authorId !== ctx.me.user.id) {
throw new AppError('Only the author can edit this idea', {
code: 'FORBIDDEN',
})
}
return {
idea: await prisma.idea.update({
where: { sn },
data: { title, content },
}),
}
})
.mutation()AppError here is your own error class (ErrorPoint0 by default, or whatever
you set via .errorClass(...) — see error handling); throwing
it from the loader rejects the mutation, and mutation.error is typed as that
class. Unlike a page — where a loader-less point never runs .ctx, so
auth must be gated in .with — a mutation has no loader-less case, so a
ctx gate inside the loader always fires.
A real endpoint
A mutation with a server loader is served over HTTP at an auto-generated path —
POST /_point0/<scope>/mutation/<kebab-name> — with the input in the request
body, and it shows up in the generated OpenAPI spec. You never write
the path; useMutation / fetchMutation route to it for you. Unlike a single
RPC-style endpoint, every mutation has its own stable URL, which is what gives
full OpenAPI coverage.
A mutation whose only loader is a .clientLoader runs in the browser and has
no endpoint (and no OpenAPI entry).
A mutation is always a POST — there's no per-mutation method or path override.
The HTTP method and a custom route are the action surface: passing a
method opens an action, not a mutation. To group endpoints under a custom
prefix, set it on the root/base; the default is _point0.
The mutation key
You rarely touch it, but knowing its shape helps when reading the cache.
getMutationKey() takes no arguments and returns a two-element tuple:
ideaCreateMutation.getMutationKey()
// [
// 'point0',
// {
// scope: 'root', // which client/root this point grows from
// type: 'mutation',
// name: 'ideaCreate',
// tags: [], // from .tag(...)
// },
// ]Unlike a query key, the mutation key carries no input — input is
per call, so it lives in each cached mutation's variables, not in the key.
tags come from .tag('a', 'b') and let you match groups of mutations.
To filter the react-query mutation cache, the exported getMutationPredicate
helper matches on id (= scope:type:name), tags, scope, type, or
name:
import { getMutationPredicate } from '@point0/core'
queryClient.getMutationCache().findAll({
predicate: getMutationPredicate({ tags: 'idea' }),
})GOTCHA: for a tags array, the mutation predicate matches if any tag is present (
some/ OR), whereas the query predicate requires all tags (every/ AND).getMutationPredicate({ tags: ['a', 'b'] })matches a mutation tagged withaorb.
Reading the mutation cache
Each .mutate(input) creates a cache entry keyed by its variables. Two
accessors read it back:
// one entry — exact input match
const m = ideaCreateMutation.getMutationCache({ id: 7 })
m?.state.variables // => { id: 7 }
m?.state.data // => the loader output, or undefined if not run yet
// many entries — three filter modes for the first argument:
ideaCreateMutation.getMutationsCache(true) // all entries for this mutation
ideaCreateMutation.getMutationsCache({ id: 7 }) // exact-input matches
ideaCreateMutation.getMutationsCache((v) => v.id === 7) // predicate over variablesgetMutationCache returns the single matching Mutation or undefined;
getMutationsCache always returns an array. Input matching is exact on the
stringified variables (using the point's transformer), and
ignores tags.
Edge cases
- Server execution throws. A mutation's
mutationFnis client-only; calling it on the server throws. For a server-side write, useengine.execute/Executor.execute, or theexecutefunction passed to a loader/ctx. - A loader can redirect.
throw redirect('ideaView', { id })inside the loader navigates the client after the mutation settles — see Navigation. - Events fire on every run. Each mutation emits
pointMutationStart, thenpointMutationSettledpluspointMutationSuccess(orpointMutationError). Subscribe with.on/.serverOn/.clientOn— see Events.
Reference
Method surface
A ready mutation point exposes:
| Method | Signature | Returns |
|---|---|---|
useMutation | (mutationOptions?, options?) | TanStack UseMutationResult |
fetchMutation | (input, mutationOptions?, options?) | Promise<data> |
getMutationOptions | (mutationOptions?, options?) | built react-query MutationOptions |
getMutationKey | () | the MutationKey tuple |
getMutationCache | (input, options?) | Mutation | undefined |
getMutationsCache | (input | predicate | true, options?) | Mutation[] |
mutationOptions is the react-query options (no
mutationFn / mutationKey); options is { fetchOptions?, queryClient? }
(members vary by method). On useMutation / getMutationOptions the input is
not a method argument — it's passed to .mutate / .mutateAsync at call time;
fetchMutation and getMutationCache take the input as their first argument.
Also exposed: id (= `${scope}:${type}:${name}`), type ('mutation'),
tags, point (the self-reference), and Infer (type inference — see
Infer).
A mutation with a server loader also exposes the lower-level server-fetch
helpers — fetchServer, fetchServerDetailed, and getFetchServerOptions —
that fire the mutation's POST directly, outside the mutation cache. (fetch
on a mutation is not one of these: it delegates to fetchMutation, running
through the mutation cache and its callbacks.) fetchServer resolves to the
loader output (throwing on error or redirect); fetchServerDetailed resolves to
{ data, response, error, redirect }, so you can read the raw status — handy
when the loader returns a [status, data] tuple. They take the input first,
then optional fetchOptions. These are an escape hatch; reach for useMutation
/ fetchMutation first.
Methods that apply to a mutation
Data & context: .loader, .clientLoader, .ctx,
.input / .clientInput / .sharedInput, .use (plugins).
Strip categories for these: .loader, .ctx, .input are server-only —
cut from the client bundle (body + the imports they use removed, so they never
ship to the browser); .clientLoader, .clientInput are client-only — cut
from the server bundle (body + imports removed); .sharedInput and .use are
server-and-client — not cut from either bundle, kept in both (isomorphic).
Shared: .middleware, .on / .serverOn / .clientOn (events),
.tag, .description, .fetchOptions, .headers, .cookies.
Strip categories: .middleware, .serverOn, .description, .headers,
.cookies are server-only — cut from the client bundle (body + imports
removed); .clientOn is client-only — cut from the server bundle (body +
imports removed); .tag, .on, and .fetchOptions are server-and-client —
not cut from either bundle, kept in both (isomorphic).
Defaults (.mutationOptions) live on the root / base /
plugin above the mutation, not on the mutation itself — it's
server-and-client: not cut from either bundle, kept in both (isomorphic).
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️