Skip to content

Optimistic updates

A mutation normally repaints the UI only once the server has confirmed the write and the affected queries have pushed new values. The optimisticUpdate option on injectMutation() closes that gap: it rewrites the client’s local query results the moment mutate() is called, so the UI reacts instantly.

optimisticUpdate?: OptimisticUpdate<FunctionArgs<Mutation>>;
type OptimisticUpdate<Args extends Record<string, Value>> = (
localQueryStore: OptimisticLocalStore,
args: Args,
) => void;

The callback receives the client’s local query store and the exact arguments passed to mutate(). It returns nothing; all it may do is read and write query results through the store.

convex-angular forwards the callback to convex.mutation(mutation, args, { optimisticUpdate }) — the lifecycle is Convex’s:

  1. mutate() runs the update against the local query store and immediately publishes the modified results to every subscriber.
  2. The mutation is sent to the server. While it is in flight the update stays applied.
  3. When the mutation completes, the local overlay is dropped and the queries fall back to server data. A successful write means the server values already reflect the change; a failed write means the UI simply reverts.

Rollback is automatic and unconditional — you never write undo logic.

localStore.getQuery(query, args) returns the locally cached result for one query/arguments pair, or undefined when this client has no subscription to it. localStore.setQuery(query, args, value) replaces it — passing undefined removes the entry and puts that query back into its loading state.

todo-list.component.ts
import { Component } from '@angular/core';
import { injectMutation, injectQuery } from 'convex-angular';
import { api, type TodoId } from './convex/api';
const LIST_ARGS = { count: 20 };
@Component({
selector: 'app-todo-list',
template: `
@for (todo of todos.data() ?? []; track todo._id) {
<label>
<input type="checkbox" [checked]="todo.completed" (change)="complete(todo._id)" />
{{ todo.title }}
</label>
}
`,
})
export class TodoListComponent {
readonly todos = injectQuery(api.todos.list, () => LIST_ARGS);
readonly completeTodo = injectMutation(api.todos.complete, {
optimisticUpdate: (localStore, args) => {
// Read the current client-side result for this exact query + args pair.
// `undefined` means the query is not loaded in this client, so there is
// nothing to update.
const todos = localStore.getQuery(api.todos.list, LIST_ARGS);
if (todos === undefined) {
return;
}
// Query results are immutable: build new objects instead of mutating.
localStore.setQuery(
api.todos.list,
LIST_ARGS,
todos.map((todo) => (todo._id === args.id ? { ...todo, completed: true } : todo)),
);
},
});
complete(id: TodoId): void {
void this.completeTodo.mutate({ id }).catch(() => undefined);
}
}

Because the update runs synchronously inside mutate(), the checkbox flips before the request leaves the browser, and un-flips by itself if the mutation rejects.

getQuery only reaches the exact arguments you name. When the same document appears in several subscriptions of one query — different filters, different page sizes — use localStore.getAllQueries(query), which returns { args, value } for every locally cached result of that query, and write each one back with its own args.

complete-todo.component.ts
import { Component } from '@angular/core';
import { injectMutation } from 'convex-angular';
import type { OptimisticUpdate } from 'convex/browser';
import { api, type TodoId } from './convex/api';
// `getAllQueries` returns every locally cached result for a query name,
// whatever arguments each subscription used. Use it when the same document can
// appear in several argument variants of the same query.
const completeTodoOptimistically: OptimisticUpdate<{ id: TodoId }> = (localStore, args) => {
for (const { args: queryArgs, value } of localStore.getAllQueries(api.todos.list)) {
if (value === undefined) {
continue;
}
localStore.setQuery(
api.todos.list,
queryArgs,
value.map((todo) => (todo._id === args.id ? { ...todo, completed: true } : todo)),
);
}
};
@Component({
selector: 'app-complete-todo',
template: `<button type="button" (click)="complete()">Complete</button>`,
})
export class CompleteTodoComponent {
readonly completeTodo = injectMutation(api.todos.complete, {
optimisticUpdate: completeTodoOptimistically,
});
complete(): void {
void this.completeTodo.mutate({ id: 'todo-id' as TodoId }).catch(() => undefined);
}
}

Paginated queries are stored as many independent page subscriptions, so hand-rolling getAllQueries over them means reimplementing cursor and sort-order rules. The library ships helpers for this — insertAtTop, insertAtBottomIfLoaded, insertAtPosition, and optimisticallyUpdateValueInPaginatedQuery. See Paginated optimistic updates.