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.