Point0by 1gr14
  • Меню
    • GitHub
    • Блог
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Capacitor
    • Expo
  • 1gr14
    • Главная
    • Start0
    • Поддержать
    • Обучение
    • Группа
    • Блог
    • Автор
  • Сообщество
    • Discord
    • Telegram
  • Опенсорс
    • Point0
    • Route0
    • Error0
    • Flat
  • Аккаунт
    • Войти
    • Регистрация
Point0by 1gr14
Создаю опенсорс во славу Господа Иисуса Христа ☦️
С любовью ко всем разработчикам на свете ❤️
Условия использованияПолитика конфиденциальностиСергей Дмитриев 2026 😎

Mutation

  • Раздел: Points

On this page

  1. Declaring a mutation
  2. The loader is the mutation body
  3. Input and validation
  4. Using a mutation
  5. react-query options
  6. Defaults from up the chain
  7. Authorization
  8. A real endpoint
  9. The mutation key
  10. Reading the mutation cache
  11. Edge cases
  12. Reference
  13. Method surface
  14. Methods that apply to a mutation

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() // close

See 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?) is server-and-client — not cut from either bundle, kept in both (isomorphic): 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.

A mutation has exactly one loader — either a server loader or a client loader, never both (see one loader per point):

// either: a 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 }) }))

// or: a client loader → 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 runs only 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 and .search (those belong to pages and actions) and not .body (actions only); all three 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 runs

Imperatively, 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({...}). .mutationOptions is server-and-client like the closer — not cut from either bundle, kept in both (isomorphic). Resolution is lowest-to-highest:

  1. root/base/plugin .mutationOptions(...)
  2. the mutation's own closing .mutation({...})
  3. 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 when the mutation runs its server loader, 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 with a server loader always runs it, so a ctx gate inside that loader always fires. (A .clientLoader-only mutation never reaches the server, so there is nothing to gate there.)

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 with a or b.

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 variables

getMutationCache 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 mutationFn is client-only; calling it on the server throws. For a server-side write, use engine.execute / Executor.execute, or the execute function 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, then pointMutationSettled plus pointMutationSuccess (or pointMutationError). Subscribe with .on / .serverOn / .clientOn — see Events.

Reference

Method surface

A ready mutation point exposes:

MethodSignatureReturns
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).

This mutation's own react-query options are the argument to the closing .mutation({...}) (see react-query options); chain-wide defaults come from .mutationOptions(...) on the root / base / plugin above. Both are server-and-client: not cut from either bundle, kept in both (isomorphic).

НазадInfinite QueryДалееAction

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter