Paginated optimistic updates
A mutation’s optimisticUpdate receives an OptimisticLocalStore, and rewriting paginated
query results through it by hand is fiddly: pages are separate cached query results keyed by
their own paginationOpts, and each has to be found and rewritten individually.
convex-angular ships five helpers that do that work.
| Helper | Purpose |
|---|---|
optimisticallyUpdateValueInPaginatedQuery |
Map every item across all cached pages. |
insertAtTop |
Prepend an item to the first page. |
insertAtBottomIfLoaded |
Append an item to the final page, if it is loaded. |
insertAtPosition |
Insert an item into the correct page and offset for a known sort order. |
sortByField |
Build a sortKeyFromItem callback from item fields. |
import { Component } from '@angular/core';import { injectMutation, insertAtBottomIfLoaded, insertAtPosition, insertAtTop, optimisticallyUpdateValueInPaginatedQuery, sortByField,} from 'convex-angular';
import { api, type Todo, type TodoId } from './convex/api';
function draftTodo(title: string): Todo { return { _id: `optimistic-${title}` as TodoId, _creationTime: Date.now(), title, description: '', completed: false, priority: 0, };}
@Component({ selector: 'app-optimistic-todos', template: `<button type="button" (click)="add('Buy milk')">Add</button>`,})export class OptimisticTodosComponent { // 1. optimisticallyUpdateValueInPaginatedQuery takes POSITIONAL arguments. readonly complete = injectMutation(api.todos.complete, { optimisticUpdate: (localStore, args) => { optimisticallyUpdateValueInPaginatedQuery(localStore, api.todos.listPaginated, {}, (todo) => todo._id === args.id ? { ...todo, completed: true } : todo, ); }, });
// 2. insertAtTop takes a single OPTIONS OBJECT. readonly addNewest = injectMutation(api.todos.create, { optimisticUpdate: (localQueryStore, args) => { insertAtTop({ paginatedQuery: api.todos.listPaginated, localQueryStore, item: draftTodo(args.title), }); }, });
// 3. insertAtBottomIfLoaded — a no-op unless the final page is loaded. readonly addOldest = injectMutation(api.todos.create, { optimisticUpdate: (localQueryStore, args) => { insertAtBottomIfLoaded({ paginatedQuery: api.todos.listPaginatedByCategory, argsToMatch: { category: 'work' }, localQueryStore, item: draftTodo(args.title), }); }, });
// 4. insertAtPosition — the sort key must reproduce the server ordering, // including a stable tie-breaker. readonly addSorted = injectMutation(api.todos.create, { optimisticUpdate: (localQueryStore, args) => { insertAtPosition({ paginatedQuery: api.todos.listPaginatedByCategory, argsToMatch: { category: 'work' }, sortOrder: 'desc', sortKeyFromItem: sortByField<Pick<Todo, 'priority' | '_creationTime'>>('priority', '_creationTime'), localQueryStore, item: draftTodo(args.title), }); }, });
add(title: string): void { void this.addSorted.mutate({ title }); }}optimisticallyUpdateValueInPaginatedQuery — positional
Section titled “optimisticallyUpdateValueInPaginatedQuery — positional”function optimisticallyUpdateValueInPaginatedQuery<Query extends PaginatedQueryReference>( localStore: OptimisticLocalStore, query: Query, args: PaginatedQueryArgs<Query>, updateValue: (currentValue: PaginatedQueryItem<Query>) => PaginatedQueryItem<Query>,): void;| Position | Name | Type | Description |
|---|---|---|---|
| 1 | localStore |
OptimisticLocalStore |
The store handed to optimisticUpdate. |
| 2 | query |
Query |
The paginated query whose cached pages to rewrite. |
| 3 | args |
PaginatedQueryArgs<Query> |
Exact non-pagination arguments. Cached pages whose args do not match are skipped. |
| 4 | updateValue |
(item) => item |
Called for every item on every matching page. Return the item unchanged to leave it alone. |
args here is an exact match on the query’s non-pagination arguments, not a partial one:
every cached page is compared by serializing its args with paginationOpts stripped. Use
{} for a query whose only argument is paginationOpts.
insertAtTop — options object
Section titled “insertAtTop — options object”function insertAtTop<Query extends PaginatedQueryReference>(options: { paginatedQuery: Query; argsToMatch?: Partial<PaginatedQueryArgs<Query>>; localQueryStore: OptimisticLocalStore; item: PaginatedQueryItem<Query>;}): void;Prepends item to the page whose paginationOpts.cursor === null — the first page. Use it
for feeds sorted newest-first.
insertAtBottomIfLoaded — options object
Section titled “insertAtBottomIfLoaded — options object”function insertAtBottomIfLoaded<Query extends PaginatedQueryReference>(options: { paginatedQuery: Query; argsToMatch?: Partial<PaginatedQueryArgs<Query>>; localQueryStore: OptimisticLocalStore; item: PaginatedQueryItem<Query>;}): void;Appends item to the final page — the one whose value has isDone === true. If the user has
not paged to the end, the final page is not in the local store and the call does nothing,
which is correct: the item will simply appear when they eventually scroll there.
insertAtPosition — options object
Section titled “insertAtPosition — options object”function insertAtPosition<Query extends PaginatedQueryReference>(options: { paginatedQuery: Query; argsToMatch?: Partial<PaginatedQueryArgs<Query>>; sortOrder: 'asc' | 'desc'; sortKeyFromItem: (element: PaginatedQueryItem<Query>) => Value | Value[]; localQueryStore: OptimisticLocalStore; item: PaginatedQueryItem<Query>;}): void;| Name | Type | Default | Description |
|---|---|---|---|
paginatedQuery |
Query |
— | The paginated query to update. |
argsToMatch |
Partial<PaginatedQueryArgs<Query>> |
match everything | Only cached pages whose args match every listed key are touched. |
sortOrder |
'asc' | 'desc' |
— | Must match the server query’s ordering. |
sortKeyFromItem |
(item) => Value | Value[] |
— | Extracts the sort key. Must reproduce the server ordering exactly. |
localQueryStore |
OptimisticLocalStore |
— | The store handed to optimisticUpdate. |
item |
PaginatedQueryItem<Query> |
— | The item to insert. |
It groups cached pages by their pagination stream, sorts the loaded pages by their first
item’s key, and places item into the page that would contain it — falling back to
prepending on the first page or appending on the final page when the key sorts outside the
loaded range.
sortKeyFromItem must reproduce the server ordering exactly
Section titled “sortKeyFromItem must reproduce the server ordering exactly”sortByField
Section titled “sortByField”function sortByField<Item extends Record<string, Value>>( ...fields: Array<keyof Item & string>): (item: Item) => Value | Value[];Builds the callback from one or more field names. Pass them in the same order the server
query sorts by, ending with the tie-breaker. A single field yields that field’s value; two or
more yield an array, which compareValues compares lexicographically.
sortKeyFromItem: sortByField<Pick<Todo, 'priority' | '_creationTime'>>('priority', '_creationTime');Silent no-ops are by design
Section titled “Silent no-ops are by design”Every insert helper returns without doing anything when the page it would have to modify is not in the local store:
| Helper | No-op when |
|---|---|
insertAtTop |
the first page (cursor === null) is not loaded, or its value is undefined |
insertAtBottomIfLoaded |
no loaded page reports isDone === true |
insertAtPosition |
no page in the group is loaded and non-empty; the key sorts before the first loaded page but that page is not the first page (cursor !== null); or the key sorts after the last loaded page but that page is not final (isDone === false) |
optimisticallyUpdateValueInPaginatedQuery |
no cached page matches args, or a matching entry has no page array |