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.
How Convex applies it
Section titled “How Convex applies it”convex-angular forwards the callback to convex.mutation(mutation, args, { optimisticUpdate }) — the lifecycle is Convex’s:
mutate()runs the update against the local query store and immediately publishes the modified results to every subscriber.- The mutation is sent to the server. While it is in flight the update stays applied.
- 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.
Worked example
Section titled “Worked example”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.
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.
Updating every argument variant
Section titled “Updating every argument variant”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.
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
Section titled “Paginated queries”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.