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:

db/tasks.ts
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:

src/task-mutations.ts
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.

src/TaskCheckbox.tsx
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:

db/tasks.ts
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:

src/task-detail-mutations.ts
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:

db/tasks.ts
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:

src/task-list-mutations.ts
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.

OutcomeWhat the view does
Mutation commitsKeeps the prediction until that query sees the commit, then uses the server result
Mutation is rejectedRemoves its prediction and retains newer confirmed data
Connection fails during submissionRemoves the prediction while the same handle checks its original result
User/account changesClears 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.