Declarative optimistic updates for Pinia Colada.
A Pinia Colada plugin that snapshots the query cache when a mutation starts, writes the expected result immediately, rolls the cache back if the mutation fails, and invalidates the affected queries once it settles. All of it is configured with a single optimistic option on useMutation instead of hand-written onMutate / onError / onSettled hooks in every mutation.
The manual pattern for optimistic updates looks like this, repeated in every mutation:
const queryCache = useQueryCache()
const { mutate: renameProject } = useMutation({
mutation: (input: { id: string; name: string }) => api.projects.rename(input),
onMutate(vars) {
const previous = queryCache.getQueryData<Project[]>(['projects'])
queryCache.setQueryData(
['projects'],
previous?.map((p) => (p.id === vars.id ? { ...p, ...vars } : p))
)
return { previous }
},
onError(_error, _vars, context) {
queryCache.setQueryData(['projects'], context.previous)
},
onSettled() {
queryCache.invalidateQueries({ key: ['projects'] })
}
})This version handles exactly one query key. If the same record is cached under several keys — a list, a filtered list, a detail view — each one needs its own snapshot and rollback.
With the plugin:
const { mutate: renameProject } = useMutation({
mutation: (input: { id: string; name: string }) => api.projects.rename(input),
optimistic: {
action: OptimisticAction.update,
key: ['projects']
}
})The plugin applies the update to every cache entry under ['projects'] (prefix match), snapshots each one for rollback, and invalidates them after the mutation settles.
vue>= 3.5@pinia/colada>= 1.3
npm install optimistic-coladaRegister the plugin in Pinia Colada options.
Vue:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { PiniaColada } from '@pinia/colada'
import { createOptimisticPlugin } from 'optimistic-colada'
const app = createApp(App)
app.use(createPinia())
app.use(PiniaColada, {
plugins: [createOptimisticPlugin()]
})Nuxt (with @pinia/colada-nuxt):
// colada.options.ts
import type { PiniaColadaOptions } from '@pinia/colada'
import { createOptimisticPlugin } from 'optimistic-colada'
export default {
plugins: [createOptimisticPlugin()]
} satisfies PiniaColadaOptionsimport { useMutation } from '@pinia/colada'
import { OptimisticAction } from 'optimistic-colada'
const { mutate: renameProject } = useMutation({
mutation: (input: { id: string; name: string }) => api.projects.rename(input),
optimistic: {
action: OptimisticAction.update,
key: ['projects']
}
})On renameProject({ id, name }), every cached query whose key starts with ['projects'] is scanned for records whose id matches the mutation variables. Matched records are shallow-merged with the variables. If the request fails, the previous data is restored. When the mutation settles, queries under ['projects'] are invalidated, so active queries refetch and the cache converges to server state.
const { mutate: deleteProject } = useMutation({
mutation: (input: { id: string }) => api.projects.delete(input),
optimistic: {
action: OptimisticAction.delete,
key: ['projects']
}
})Matching records are removed from arrays and paginated results. In paginated shapes, total is decremented by the number of removed records.
There is no built-in updater for create — the shape of a new record cannot be inferred from mutation variables. Provide one:
import { OptimisticAction, createOptimisticId } from 'optimistic-colada'
const { mutate: createTask } = useMutation({
mutation: (input: { title: string }) => api.tasks.create(input),
optimistic: {
action: OptimisticAction.create,
key: ['tasks'],
updater: (cached, input) => [
...(cached as Task[]),
{ id: createOptimisticId(), title: input.title, done: false }
]
}
})createOptimisticId() returns a prefixed UUID for the placeholder record, and isOptimisticId(id) lets components tell placeholders from persisted records — for example, to disable row actions until the record is real. The invalidation after settle replaces placeholders with server data.
An updater overrides the built-in behavior for any action. It is called once per matching cache entry with the current cached data and the mutation variables, and must return the new data without mutating the input. Return undefined or the same reference to leave an entry untouched.
optimistic: {
action: OptimisticAction.update,
key: ['tasks'],
updater: (cached, input) => {
if (!Array.isArray(cached)) return undefined // skip this entry
return cached.map((t) => (t.id === input.id ? { ...t, done: input.done } : t))
}
}optimistic: {
action: OptimisticAction.update,
key: ['settings'],
skipInvalidation: true
}By default, queries under key are invalidated after a successful mutation. Set skipInvalidation: true when the optimistic write already is the final state and a refetch adds nothing. On error, rollback and invalidation happen regardless of this flag.
Built-in updaters match records by id. If your records use a different field:
createOptimisticPlugin({ identityKey: 'uuid' })Built-in updaters detect the shape of each cache entry independently:
| Shape | Example | update |
delete |
|---|---|---|---|
| Array | [{ id, ... }, ...] |
shallow-merge variables into matched records | remove matched records |
| Paginated | { items: [...], total? } |
same, inside items |
remove from items, decrement total |
| Single | { id, ... } |
shallow-merge when id matches |
skipped |
Entries with any other shape are skipped, with a console warning in dev builds. Entries that have no data yet are skipped silently.
- On
mutate, the plugin reads theoptimisticconfig from the mutation options and collects every cache entry matchingkey(prefix match, same semantics asqueryCache.getEntries). - For each entry it computes the optimistic data — via
updaterif provided, otherwise via the built-in updater for the action — takes a snapshot of the current data (deep clone, aware of arrays, plain objects,Date,Map,Set), and writes the optimistic data withsetQueryData. - On success, queries under
keyare invalidated (unlessskipInvalidation), so active queries refetch and optimistic data is replaced with server state. - On error, snapshots are restored and queries under
keyare invalidated to resynchronize with the server.
| Option | Type | Default | Description |
|---|---|---|---|
identityKey |
string |
'id' |
Field used by built-in updaters to match records |
The package augments UseMutationOptions, so once it is imported anywhere in the project, optimistic is available and typed on every useMutation call.
| Field | Type | Required | Description |
|---|---|---|---|
action |
OptimisticAction.create | update | delete |
yes | Selects the built-in updater |
key |
EntryKey |
yes | Query key prefix; all matching entries are updated |
updater |
(cachedData, vars) => unknown |
for create |
Custom per-entry transform, overrides the built-in updater |
skipInvalidation |
boolean |
no | Skip query invalidation after a successful mutation |
applyUpdate(data, input, identityKey), applyDelete(data, input, identityKey) and detectShape(data) are exported for reuse inside custom updaters and in tests.
createOptimisticId(), isOptimisticId(id), OPTIMISTIC_PREFIX — helpers for placeholder records in create flows.
- Built-in updaters require the mutation variables to be a plain object carrying the identity key, e.g.
{ id, ...patch }. With non-object variables and noupdater, the mutation runs without an optimistic write. updateshallow-merges the full variables object into matched records. Variables that are not record fields (flags, nested filter params) will show up in the cached record until invalidation refetches it; use a customupdaterin that case.- Rollback restores per-entry snapshots taken when the mutation started. If several mutations touch the same entries concurrently, a rollback can briefly restore data from before a neighboring mutation — the invalidation that follows brings the entries back to server state.
- Mutations without an
optimisticconfig are ignored by the plugin.
npm install
npm test # vitest
npm run typecheck
npm run build # tsup -> dist/