Skip to content

Prains/optimistic-colada

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

optimistic-colada

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.

Why

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.

Requirements

  • vue >= 3.5
  • @pinia/colada >= 1.3

Installation

npm install optimistic-colada

Setup

Register 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 PiniaColadaOptions

Usage

Update

import { 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.

Delete

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.

Create

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.

Custom updater

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))
  }
}

Skipping invalidation

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.

Identity key

Built-in updaters match records by id. If your records use a different field:

createOptimisticPlugin({ identityKey: 'uuid' })

Supported cache shapes

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.

How it works

  1. On mutate, the plugin reads the optimistic config from the mutation options and collects every cache entry matching key (prefix match, same semantics as queryCache.getEntries).
  2. For each entry it computes the optimistic data — via updater if 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 with setQueryData.
  3. On success, queries under key are invalidated (unless skipInvalidation), so active queries refetch and optimistic data is replaced with server state.
  4. On error, snapshots are restored and queries under key are invalidated to resynchronize with the server.

API

createOptimisticPlugin(options?): PiniaColadaPlugin

Option Type Default Description
identityKey string 'id' Field used by built-in updaters to match records

The optimistic mutation option

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

Standalone updaters

applyUpdate(data, input, identityKey), applyDelete(data, input, identityKey) and detectShape(data) are exported for reuse inside custom updaters and in tests.

Optimistic IDs

createOptimisticId(), isOptimisticId(id), OPTIMISTIC_PREFIX — helpers for placeholder records in create flows.

Behavior notes

  • 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 no updater, the mutation runs without an optimistic write.
  • update shallow-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 custom updater in 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 optimistic config are ignored by the plugin.

Development

npm install
npm test        # vitest
npm run typecheck
npm run build   # tsup -> dist/

License

MIT

About

Declarative optimistic updates plugin for Pinia Colada

Topics

Resources

License

Stars

5 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors