Skip to content

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.
optimistic-todos.component.ts
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.

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.

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.

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”
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');

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