Writing Plugins
Plugins are install-time functions that subscribe to Pinia Colada cache actions allowing you to observe and modify query/mutation lifecycles globally without needing to change your components or the core library.
This behavior is based on Pinia's $onAction() subscriptions and is possible because Pinia Colada is built on top of Pinia stores. Under the hood, useQuery() and useMutation() are composables that trigger actions on their respective caches (queryCache and mutationCache) to perform their work.
Most plugin work falls into one of these categories:
- add extra reactive fields to
useQuery()/useMutation()return values (entry extensions) - add new options to queries/mutations (via TypeScript module augmentation)
- observe outcomes centrally (logging, analytics, notifications)
- coordinate timers/retries/refetch policies
TIP
Pinia Colada's core is intentionally minimal. The plugin system is designed to be powerful and flexible enough to cover a wide range of use cases, from simple extensions to complex behaviors like retries or persistence. If you find something is missing, consider implementing it as a plugin! In most cases, you can just point an agent like Claude, Codex, Cursor, ... the codebase of the delay plugin as an example and ask it to generate a new plugin with the desired behavior.
Conventions
Pinia Colada plugins:
- should have a clear name with
pinia-colada-plugin-prefix. - include
pinia-colada-pluginkeyword in package.json.
Plugin Options
Users will install plugins to the project dependencies and configure them using the plugins array option when registering Pinia Colada:
import { PiniaColada } from '@pinia/colada'
import PiniaColadaFeaturePlugin from 'pinia-colada-plugin-feature'
app.use(PiniaColada, {
plugins: [PiniaColadaFeaturePlugin(options)],
})Plugins are installed in array order. If multiple plugins hook the same cache action, they will run in installation order.
Plugin Structure
Plugins should be factory functions that accept options and return a function that receives the plugin context.
import { type PiniaColadaPlugin, useMutationCache } from '@pinia/colada'
/**
* Options for the Pinia Colada Feature Plugin.
*/
export interface FeaturePluginOptions {
/**
* Example option for the plugin.
*
* @default 'default value'
*/
foo?: string
}
const FEATURE_OPTIONS_DEFAULTS = {
foo: 'default value',
} satisfies Required<FeaturePluginOptions>
/**
* Example plugin that observes query and mutation lifecycles.
*
* @param options - plugin options
*/
export function PiniaColadaFeaturePlugin(options: FeaturePluginOptions = {}): PiniaColadaPlugin {
return ({ queryCache, pinia }) => {
queryCache.$onAction(({ name, args }) => {
if (name === 'setQueryData') {
// args type gets narrowed down to the correct type
const [queryKey, data] = args
} else {
// ...
}
})
// Mutations are tree-shakable, so the mutation cache
// is not part of the plugin context. Opt in explicitly.
const mutationCache = useMutationCache(pinia)
mutationCache.$onAction(({ name, args }) => {
if (name === 'mutate') {
const [entry] = args
}
})
}
}Plugin Context
The plugin context is automatically passed to the plugin function and includes:
queryCache: the Query Cache store (powersuseQuery()and query utilities)pinia: the Pinia instance (use it to access other stores, like the mutation cache)scope: a VueEffectScopefor any reactive work your plugin creates (ref(),watch(), ...)
When using a factory function, the context is passed to the returned function:
import { type PiniaColadaPlugin } from '@pinia/colada'
export function PiniaColadaFeaturePlugin(): PiniaColadaPlugin {
return ({ queryCache, pinia, scope }) => {
// ...
}
}Plugin Subscriptions
Pinia Colada plugins rely on Pinia's action subscriptions.
When you subscribe to a store with $onAction(), Pinia gives you:
name: the action nameargs: the action parametersafter(cb): register a callback that runs when the action completes successfullyonError(cb): register a callback that runs when the action throws/rejects
queryCache.$onAction(({ name, args, after, onError }) => {
// before context where you can create shared variables
if (name === 'fetch') {
const [entry] = args
} else if (name === 'setEntryState') {
const [entry, state] = args
}
// this will trigger if the action succeeds and after it has fully run.
// it waits for any returned promise
after((result) => {
console.log('Action succeeded with result:', result)
})
// this will trigger if the action throws or returns a promise that rejects
onError((error) => {
console.error('Action failed with error:', error)
})
})Observing cache actions is the core mechanism for plugins. You subscribe to the relevant cache (queryCache or mutationCache) and you react to the actions that are relevant for your plugin's behavior (e.g. fetch for query hooks, mutate for mutation hooks, setEntryState for any state change, etc.).
This works because useQuery() and useMutation() are composables that only trigger cache actions to perform their work.
For example, useQuery() calls queryCache.fetch() to execute the query function and queryCache.setEntryState() to update the state. By observing these actions, plugins can hook into the lifecycle of queries and mutations without needing to modify the core logic of useQuery() or useMutation().
Plugins for useQuery()
useQuery() uses the query cache store under the hood, so to hook into useQuery()'s lifecycle, subscribe to the queryCache actions.
Before writing a plugin to augment or modify useQuery()'s behavior, it's important to understand its lifecycle:
useQuery()callsqueryCache.ensure(options, previousEntry?)to get (or create) an entry.- On the first ensure, the store triggers
extend(entry)once so plugins can attach extensions. queryCache.fetch(entry, options?)executes the user'squery()function.queryCache.setEntryState(entry, state)is the canonical state-update choke point.queryCache.remove(entry)removes an entry (manual or GC).
All these steps happen when using useQuery() and are observable via the query cache actions.
Most query plugins hook into:
extend(entry)to defineentry.ext.*fetch(entry, options?)to observe success/error of the query functionsetEntryState(entry, state)to observe any state change (includingsetQueryData())remove(entry)to cleanup timers/resources
TIP
fetch() rethrows errors. Aborted/outdated requests may not update state, but the action can still reject, your onError() handler might see these.
Cache Keys
The cache keys are used to identify queries in the cache. The key passed to queries gets serialized deterministically with toCacheKey(). This value is saved via entry.keyHash and is available in entries too.
import { toCacheKey, useQueryCache } from '@pinia/colada'
const key = toCacheKey(['users', 1, { type: 'friends' }])
const queryCache = useQueryCache()
queryCache.getEntries().at(0)?.keyHashQuery Hooks
Pinia Colada intentionally doesn't provide per-component onSuccess/onError/onSettled callbacks for queries. For global query callbacks, use PiniaColadaQueryHooksPlugin.
Internally, it listens to the query cache fetch action and wires after() / onError().
TIP
This plugin observes fetches. If you need "any data change" semantics, hook setEntryState.
Plugins for useMutation()
useMutation() uses the mutation cache store under the hood, so to hook into useMutation()'s lifecycle, subscribe to the mutationCache actions.
INFO
You need to opt in to the mutation cache by calling useMutationCache(pinia) in your plugin. This is intentional to tree shake the mutation cache if it's not used in your app.
import { useMutationCache, type PiniaColadaPlugin } from '@pinia/colada'
export function PiniaColadaFeaturePlugin(): PiniaColadaPlugin {
return ({ pinia }) => {
const mutationCache = useMutationCache(pinia)
mutationCache.$onAction(({ name, args }) => {})
}
}Before writing a plugin to augment or modify useMutation()'s behavior, it's important to understand its lifecycle:
useMutation()creates an initial entry viamutationCache.create(options).- The store triggers
extend(entry)immediately souseMutation()can expose extensions right away. - Each
mutateAsync(vars)ensures an entry viamutationCache.ensure(entry, vars)and then runsmutationCache.mutate(entry). - State updates go through
mutationCache.setEntryState(entry, state).
Most mutation plugins hook into:
extend(entry)to defineentry.ext.*setEntryState(entry, state)to react to success/error transitionsremove(entry)for cleanup
Plugin Patterns
Adding options
To add new options to queries or mutations, use TypeScript module augmentation to extend UseQueryOptions / UseMutationOptions and their global counterparts.
import { type PiniaColadaPlugin } from '@pinia/colada'
export interface FeaturePluginOptions {
myOption?: boolean
}
export function PiniaColadaFeaturePlugin(options: FeaturePluginOptions = {}): PiniaColadaPlugin {
return () => {
// plugin implementation
}
}
declare module '@pinia/colada' {
interface UseQueryOptions<TData, TError, TDataInitial> extends FeaturePluginOptions {}
interface UseQueryOptionsGlobal extends FeaturePluginOptions {}
}Then you can set global defaults for your options when creating Pinia Colada:
import { PiniaColada } from '@pinia/colada'
app.use(PiniaColada, {
queryOptions: {
myOption: true,
},
})For a detailed TypeScript example, see the Typescript section below.
Adding entry extensions
Entry extensions are properties stored on entry.ext that become available on the objects returned by useQuery() and useMutation().
import { type PiniaColadaPlugin } from '@pinia/colada'
export function PiniaColadaFeaturePlugin(): PiniaColadaPlugin {
return ({ queryCache, scope }) => {
queryCache.$onAction(({ name, args }) => {
if (name === 'extend') {
const [entry] = args
// When defining reactive extensions, make sure to create them inside `scope.run()` so they are properly disposed when the entry is removed.
scope.run(() => {
entry.ext.myField = shallowRef<string>('initial value') // This will be reactive and available on the query return value
})
}
})
}
}
declare module '@pinia/colada' {
interface UseQueryEntryExtensions<TData, TError, TDataInitial> {
/**
* Example of an extension field added by the plugin.
*/
myField: ShallowRef<string>
}
}<script setup lang="ts">
const { myField } = useQuery({
key: ['todos'],
query: () => fetchTodos(),
})
</script>Of course, this is also possible for mutations by hooking the mutation cache and augmenting UseMutationEntryExtensions.
import { useMutationCache, type PiniaColadaPlugin } from '@pinia/colada'
export function PiniaColadaFeaturePlugin(): PiniaColadaPlugin {
return ({ pinia, scope }) => {
const mutationCache = useMutationCache(pinia)
mutationCache.$onAction(({ name, args }) => {
if (name === 'extend') {
const [entry] = args
// When defining reactive extensions, make sure to create them inside `scope.run()` so they are properly disposed when the entry is removed.
scope.run(() => {
entry.ext.myField = shallowRef<string>('initial value') // This will be reactive and available on the mutation return value
})
}
})
}
}
declare module '@pinia/colada' {
interface UseMutationEntryExtensions<TData, TVars, TError, TContext> {
/**
* Example of an extension field added by the plugin.
*/
myField: ShallowRef<string>
}
}<script setup lang="ts">
const { myField } = useMutation({
mutation: (vars) => doSomething(vars),
})
</script>You should only add new keys to entry.ext during the extend action as it is shared across all plugins and it's called only once per entry. Do not assign it a completely new object.
Also, you can't add new keys to entry.ext later (e.g. in fetch or setEntryState). You must define all the needed keys in extend so they are available on the return value from the start.
Examples
Add dataUpdatedAt to queries:
import type { PiniaColadaPlugin } from '@pinia/colada'
import { type ShallowRef, shallowRef } from 'vue'
/**
* Adds a `dataUpdatedAt` property to queries that represents the last time the
* data was updated.
*/
export function PiniaColadaDataUpdatedAtPlugin(): PiniaColadaPlugin {
return ({ queryCache, scope }) => {
queryCache.$onAction(({ name, args, after }) => {
// Use the `extend` action to add custom properties
if (name === 'extend') {
const [entry] = args
// All effects must be created within the scope
scope.run(() => {
entry.ext.dataUpdatedAt = shallowRef<number>(entry.when)
})
} else if (name === 'setEntryState') {
const [entry] = args
after(() => {
entry.ext.dataUpdatedAt.value = entry.when
})
}
})
}
}
declare module '@pinia/colada' {
interface UseQueryEntryExtensions<TData, TError, TDataInitial> {
/**
* Time stamp of the last time the data was updated.
*/
dataUpdatedAt: ShallowRef<number>
}
}Then you can use dataUpdatedAt in your components:
<script setup lang="ts">
const { dataUpdatedAt } = useQuery({
key: ['todos'],
query: () => fetchTodos(),
})
</script>Another example, add mutatedAt to mutations:
import { type PiniaColadaPlugin, useMutationCache } from '@pinia/colada'
import { type ShallowRef, shallowRef } from 'vue'
export function PiniaColadaMutatedAtPlugin(): PiniaColadaPlugin {
return ({ pinia, scope }) => {
const mutationCache = useMutationCache(pinia)
mutationCache.$onAction(({ name, args, after }) => {
if (name === 'extend') {
const [entry] = args
scope.run(() => {
entry.ext.mutatedAt = shallowRef(0)
})
} else if (name === 'setEntryState') {
const [entry, state] = args
after(() => {
if (state.status === 'success') {
entry.ext.mutatedAt.value = entry.when
}
})
}
})
}
}
declare module '@pinia/colada' {
interface UseMutationEntryExtensions<TData, TVars, TError, TContext> {
/**
* Timestamp of the last successful mutation.
*/
mutatedAt: ShallowRef<number>
}
}Then you can use mutatedAt in your components:
<script setup lang="ts">
const { mutatedAt } = useMutation({
mutation: (vars) => doSomething(vars),
})
</script>TypeScript
Plugins often need to augment types (options and/or entry extensions). This is done with module augmentation.
Adding options to queries
Augment both UseQueryOptions (generic) and UseQueryOptionsGlobal (global defaults):
export interface MyQueryPluginOptions {
myOption?: boolean
}
declare module '@pinia/colada' {
interface UseQueryOptions<TData, TError, TDataInitial> extends MyQueryPluginOptions {}
interface UseQueryOptionsGlobal extends MyQueryPluginOptions {}
}INFO
You must keep all the generic parameters (TData, TError, TDataInitial) even if you don't use them, otherwise you'll get type errors.
Global options are set when creating Pinia Colada:
import { PiniaColada } from '@pinia/colada'
app.use(PiniaColada, {
queryOptions: {
myOption: true,
},
})Adding extensions to queries
Augment UseQueryEntryExtensions:
declare module '@pinia/colada' {
interface UseQueryEntryExtensions<TData, TError, TDataInitial> {
myField: string
}
}WARNING
You must keep all the generic parameters (TData, TError) even if you don't use them, otherwise you'll get type errors.
Then you can set entry.ext.myField in the plugin and it will be available on the query return value.
<script setup lang="ts">
const { myField } = useQuery({
key: ['todos'],
query: () => fetchTodos(),
})
</script>Adding options to mutations
Augment both UseMutationOptions (generic) and UseMutationOptionsGlobal:
export interface MyMutationPluginOptions {
myOption?: boolean
}
declare module '@pinia/colada' {
interface UseMutationOptions<TData, TVars, TError, TContext> extends MyMutationPluginOptions {}
interface UseMutationOptionsGlobal extends MyMutationPluginOptions {}
}INFO
You must keep all the generic parameters (TData, TVars, TError, TContext) even if you don't use them, otherwise you'll get type errors.
Global options are set when creating Pinia Colada:
import { PiniaColada } from '@pinia/colada'
app.use(PiniaColada, {
mutationOptions: {
myOption: true,
},
})Adding extensions to mutations
Augment UseMutationEntryExtensions:
declare module '@pinia/colada' {
interface UseMutationEntryExtensions<TData, TVars, TError, TContext> {
myField: string
}
}WARNING
You must keep all the generic parameters (TData, TVars, TError, TContext) even if you don't use them, otherwise you'll get type errors.
Then you can set entry.ext.myField in the plugin and it will be available on the mutation return value.
<script setup lang="ts">
const { myField } = useMutation({
mutation: (vars) => doSomething(vars),
})
</script>