Database
Optimistic updates
Show a predicted change while a mutation runs, then let the server result settle the view.
On this page
Show a task as completed as soon as the user clicks, while the mutation is still running. Use .withOptimistic(...) to predict that change in a loaded query result; the server’s result settles the view afterward.
Add this mutation beside the quickstart’s list query:
export const setCompleted = mutation({
input: z.object({ id: z.string().uuid(), completed: z.boolean() }),
handler: (ctx, input) =>
ctx.db
.update('tasks', { completed: input.completed })
.where({ id: input.id })
.expectRows(1),
})This example uses the public quickstart table. A private table also needs an ownership condition and verified-identity guard. Regenerate before importing the new client callable.
Put a wrapper in a browser application module. It identifies the query result to change and returns an immutable replacement:
import { list, setCompleted } from '../db/_generated/client/tasks'
export const completeTask = setCompleted.withOptimistic({
query: list,
update: (tasks, input) =>
tasks.map((task) =>
task.id === input.id ? { ...task, completed: input.completed } : task
),
})The list query has no input, so no input mapping is needed. The SDK finds its
cache entry using the generated query and the current auth context. You do not
construct a cache key or declare a runtime predictor.
Only an existing cached result can be updated. If it is absent, the prediction is skipped; the mutation still runs. An empty array is a real result. The updater must return the query’s result shape and cannot create server authorization.
import { useMutation } from '@hulla/db-react'
import { completeTask } from './task-mutations'
export function TaskCheckbox({
id,
completed,
}: {
id: string
completed: boolean
}) {
const save = useMutation(completeTask)
return (
<input
type="checkbox"
checked={completed}
onChange={(event) => {
void save({ id, completed: event.currentTarget.checked })
}}
/>
)
}The parent reads the live list query and passes its task into this checkbox.
The prediction changes that result immediately; the mutation still resolves
only with its authoritative server response. Use save.state for error/pending
UI, or await completeTask({ id, completed: true }) outside a component.
Use the original setCompleted export for a server-confirmed call. There is no
per-call optimistic mode switch; export the wrapper where prediction is intended.
For a query that accepts input, map the mutation’s input to the query input.
For example, add a single-task query beside the quickstart declarations in
db/tasks.ts, which already imports query and z:
export const get = query({
input: z.object({ id: z.string().uuid() }),
handler: (ctx, input) =>
ctx.db.select('tasks').where({ id: input.id }).unique(),
})After regenerating, wrap the mutation to update only that task’s cached result:
import { get, setCompleted } from '../db/_generated/client/tasks'
export const completeTaskDetail = setCompleted.withOptimistic({
query: get,
input: ({ id }) => ({ id }),
update: (task, input) =>
task ? { ...task, completed: input.completed } : task,
})Calling completeTaskDetail({ id, completed: true }) predicts only get({ id }).
The updater preserves null when that query has no matching task. It does not
create a result when the query has never been loaded.
Use match when several input variants should receive the same change. For
example, different views can request different list sizes:
export const listWithLimit = query({
input: z.object({ limit: z.number().int().min(1).max(100) }),
handler: (ctx, input) =>
ctx.db.select('tasks').orderBy('title').limit(input.limit),
})Regenerate, then target every already-loaded variant without an input mapper:
import { listWithLimit, setCompleted } from '../db/_generated/client/tasks'
export const completeTaskLists = setCompleted.withOptimistic({
query: listWithLimit,
match: () => true,
update: (tasks, input) =>
tasks.map((task) =>
task.id === input.id ? { ...task, completed: input.completed } : task
),
})This updates the task wherever it appears in cached results such as
listWithLimit({ limit: 10 }) and listWithLimit({ limit: 50 }). It does not
fetch other variants. Changing completed preserves this query’s title ordering
and membership; a query filtered by completion would need an updater that also
respects that filter.
The full callbacks are match(queryInput, mutationInput) and
update(current, mutationInput, queryInput), with each target’s types inferred.
Choose input for an exact cache entry or match for cached variants. Input-bearing
queries require a selector; no-input queries need neither.
Pass multiple target objects to .withOptimistic(first, second) when one mutation
affects several queries. Each target keeps its own selection and updater.
Updaters are synchronous, pure functions and may run again over a newer confirmed base. Return new arrays/objects. Capture temporary IDs or other nondeterministic values before starting the call. An invalid prediction is removed without preventing the real mutation from executing.
| Outcome | What the view does |
|---|---|
| Mutation commits | Keeps the prediction until that query sees the commit, then uses the server result |
| Mutation is rejected | Removes its prediction and retains newer confirmed data |
| Connection fails during submission | Removes the prediction while the same handle checks its original result |
| User/account changes | Clears the old account’s results and predictions |
Overlapping predictions are replayed over the latest confirmed base, so rejecting one does not restore an obsolete snapshot or erase someone else’s committed edit. The SDK does not persist predictions or resubmit offline writes after a reload. See mutation confirmation for interrupted calls.