Skip to content
result-rpc
Esc
navigateopen⌘Jpreview
On this page

Mutations

Result-state mutations, optimistic arcs, and declared invalidation: the blast radius lives in the contract.

import { useResultMutation } from "result-rpc/react"
import { client } from "./client"

function RenameDoc({ id }: { id: string }) {
  const rename = useResultMutation(client.doc.rename, {
    optimistic: ({ title }, cache) => {
      const rollback = cache.update(
        client.doc.byId,
        { id },
        doc => doc && { ...doc, title },
      )

      return { rollback }
    },
    onFailure: (_error, _input, context) => {
      context?.rollback()
    },
    onCancel: (_input, context) => {
      context?.rollback()
    },
  })

  async function submit(title: string) {
    const result = await rename.mutate({ id, title })

    if (!result.ok && result.error._tag === "doc/title-conflict") {
      focusTitleField()
    }
  }

  return <RenameForm pending={rename.state === "pending"} onSubmit={submit} />
}

Write the options inline, exactly as above. The hook reads them through a ref: the observer is created once per procedure, inline objects and inline callbacks never resubscribe it, and the current render’s callbacks are the ones that fire. (MutationOptions and QueryCache are exported from result-rpc/react when you want to hoist an options object anyway.)

AuthShell.useMutation is the narrowed form: claimed failures never reach onFailure or the returned state, and the mutate promise rejects with the distinguishable claimed signal, as described under What a claimed error does.

Optimistic rollback runs before observers receive the final failure state. Cancellation is explicit because cancelling a request cannot guarantee that a server-side mutation did not happen: call rename.cancel(). Cancellation resets lifecycle state and rejects the pending mutate promise with the cancelled control sentinel; it never appears as an operation Err.

Declared invalidation

You noticed what the example above does not contain: onSettled with a cache.invalidate call. That line — the most-repeated and most-forgotten line in any React Query app, whose absence is a stale-UI bug — lives in the contract now. A mutation declares its blast radius once, where it is defined:

const byId = app.procedure()
  .input(wire.object({ id: wire.string }))
  .output(DocCodec)
  .query()

const rename = app.procedure()
  .input(wire.object({ id: wire.string, title: wire.string }))
  .output(DocCodec)
  .affects(byId, (input) => ({ id: input.id }))   // rename touches this doc
  .affects(list)                                  // and every cached list page
  .mutation()

Every useResultMutation of rename — in any component, forever — invalidates those queries on success. The map turns the mutation’s input into the target’s input; omitting it invalidates every cached input of the target. The declaration is also documentation: the contract states which reads each write disturbs, and a reviewer can see a missing .affects() in the same diff that adds the mutation. Server-driven invalidation (the handler reporting what it actually touched, over the response envelope) is the planned extension of the same channel — and with entities (next section), .affects() recedes to what only a declaration can express: membership.

Was this page helpful?