Plugins and orchestration
Each plugin is a modular addition to the manager. Attach them with createStateManager().use(...), then opt individual stores into the matching feature through their definition options.
How the plugin model fits together
import {
createAsyncPlugin,
createHistoryPlugin,
createPersistencePlugin,
createStateManager,
createSyncPlugin
} from '@selfagency/stately';
const manager = createStateManager()
.use(createPersistencePlugin())
.use(createHistoryPlugin())
.use(createSyncPlugin())
.use(createAsyncPlugin());Each plugin augments stores only when the store definition opts into the matching feature.
createPersistencePlugin()
The persistence plugin hydrates state from storage and writes snapshots back through the store’s mutation pipeline.
Use it when a store should survive reloads, sessions, or app restarts.
Key behavior:
- requires a
persistoption with aversionand aPersistenceAdapter - exposes
$persist.ready,$persist.flush(),$persist.rehydrate(),$persist.clear(),$persist.pause(), and$persist.resume() - queues writes so older snapshots do not overwrite newer ones
- cancels any pending debounced flush before
$persist.clear()removes stored state - supports optional compression and custom serialize/deserialize hooks
- suppresses writes while replaying history or during explicit pause/rehydrate flows
- preserves the concrete store state type through custom persistence hooks and
PersistEnvelope<State>
import {
createLocalStorageAdapter,
createPersistencePlugin,
createStateManager,
defineStore
} from '@selfagency/stately';
const manager = createStateManager().use(createPersistencePlugin());
export const useSessionStore = defineStore('session', {
state: () => ({ theme: 'dark' }),
persist: {
adapter: createLocalStorageAdapter(),
key: 'stately:session',
version: 1
}
});See Persistence helpers for adapter and compression options.
createHistoryPlugin()
The history plugin records snapshots and adds undo/redo/time-travel helpers.
Use it for draft editing, debugging, or user-facing time travel.
Key behavior:
- requires a
historyoption on the store definition - exposes
$historyand$timeTravel - supports
undo(),redo(),goTo(index),record(snapshot),startBatch(), andendBatch()through the history controller - replays snapshots without re-triggering history recording
- avoids persistence and sync feedback loops during time travel
- exposes
canUndo,canRedo,entries, andcurrentIndexthrough$history - preserves the concrete store state type through history entries and
$timeTravel.entries
$timeTravel controller
$timeTravel exposes the history stack for rendering and navigation. It does not expose mutation helpers such as record, undo, or redo — those belong to $history. However, goTo(index) does replay a past snapshot and mutates live store state; it is read-only only in the sense that it does not record a new history entry.
| Property / Method | Description |
|---|---|
entries | The full history stack as HistoryEntry[], newest first |
currentIndex | Index of the currently active entry |
isReplaying | true while a goTo() replay is in progress |
goTo(index) | Jump to a specific history index; returns false when the index is out of range |
While isReplaying is true, pending persistence flushes and sync publications are suppressed so the jump does not produce spurious side effects.
import { createHistoryPlugin, createStateManager, defineStore } from '@selfagency/stately';
const manager = createStateManager().use(createHistoryPlugin());
export const useDraftStore = defineStore('draft', {
state: () => ({ body: '' }),
history: { limit: 25 }
});Use startBatch() and endBatch() when several mutations should become one logical history entry.
createFsmPlugin()
The FSM plugin adds explicit workflow state to stores that declare an fsm definition.
Use it when a store should move through named states rather than coordinating a pile of booleans.
Key behavior:
- requires an
fsmoption withinitialandstates - adds
$fsm.current,$fsm.send(),$fsm.matches(), and$fsm.can() - patches transitions through the store so history, persistence, and sync can observe them
- stores the current state in an internal
__stately_fsmkey for plugin interoperability
Read Finite state machines for the exact option and controller contracts.
createValidationPlugin()
The validation plugin wraps $patch() for stores that declare validate.
Use it when invalid state should be rolled back immediately.
Key behavior:
- runs after the patch is applied
- accepts the mutation when
validate()returnstrueorundefined - restores the previous snapshot and throws
Error('Validation failed')whenvalidate()returnsfalse; callsonValidationErrorfirst if present - restores the previous snapshot when
validate()returns an error string; callsonValidationErrorbefore throwing - restores the previous snapshot and rethrows if
validate()itself throws - preserves the concrete store state type inside
validate(state)callbacks
Read Validation for the full contract.
createSyncPlugin(options?)
The sync plugin publishes store snapshots across tabs or embedded environments and applies validated inbound updates to matching stores.
Use it when multiple browser contexts should stay in sync.
Key behavior:
- ignores self-originated messages
- rejects mismatched versions
- rejects stale same-origin
mutationIdvalues and older cross-origin updates once a newer mutation has been applied locally or remotely - uses a last-write-wins policy where the winner is determined by timestamp, then origin name, then
mutationId— this is deterministic but does not guarantee causal consistency; if you require conflict-free merging, supply a customtransportsbridge and handle merging in your own publish/subscribe adapter - only patches known state keys
- cleans up transports during
$dispose()
Conflict ordering works like this:
- newer
timestampwins - if timestamps match, origin name order breaks the tie deterministically
- if the origin also matches, higher
mutationIdwins
That keeps the sync behavior deterministic even when two contexts publish at nearly the same time.
Important options:
originto identify the current tab or instanceversionto reject incompatible payloadschannelName/storageKeyto customize the default transport stacktransportsto supply your own publish/subscribe bridgecreateIdfor per-origin monotonic mutation idscreateTimestampfor deterministic tests and cross-origin conflict orderingcreateMessageto attach extra metadata to outgoing sync payloads
import { createStateManager, createSyncPlugin, defineStore } from '@selfagency/stately';
const manager = createStateManager().use(createSyncPlugin({ origin: 'local-tab' }));If you need a custom wire format, supply createMessage. If you need a custom publish/subscribe bridge, supply transports.
createMessage(base) is intentionally typed against a manager-wide, state-agnostic SyncMessage<object> input. A single sync plugin instance can service many stores with different state shapes, so store-specific narrowing is the caller’s responsibility when enriching the outgoing message.
Inbound sync payloads are validated as object state, then filtered to the current store’s known keys before patching. That keeps the public transport type flexible while matching the runtime safety checks.
createAsyncPlugin(options?)
The async plugin tracks action state and wraps matching actions with concurrency control.
Use it when actions can overlap, be cancelled, or need loading/error metadata.
Key behavior:
- adds a
$asyncregistry keyed by action name - wraps matching actions and keeps the action hook semantics intact
- automatically tracks actions declared with the
asynckeyword - supports
includeto limit which actions are tracked and to explicitly opt promise-returning actions into tracking when they are declared withoutasync - supports
policiesand a sharedpolicyoverride for concurrency control - can inject an
AbortSignalinto actions for cancellation flows
Supported policies:
parallelrestartabledropenqueuededupe
Policy guidance:
parallel— let every invocation run concurrently with no controlrestartable— abort the current in-flight request when a new invocation arrivesdrop— ignore new invocations while one is already activeenqueue— run invocations sequentially, queuing new ones behind the running requestdedupe— if an identical invocation is already running, return that same promise rather than starting a second request; a new invocation starts only after the first resolves
import { createAsyncPlugin, createStateManager, defineStore } from '@selfagency/stately';
const manager = createStateManager().use(
createAsyncPlugin({
include: ['loadCount'],
policies: { loadCount: 'restartable' },
injectSignal(signal, args) {
return [signal, ...args];
}
})
);If you expect cancellation to work, wire injectSignal so the wrapped action actually receives the AbortSignal. The plugin cannot guess your argument order.
Working with plugin cleanup
Plugins commonly extend $dispose() to clean up subscriptions, transports, or other external resources. That means you should treat $dispose() as the store’s teardown point, not just a convenience method.
Development inspector
The inspector is not a state-manager plugin, so it is documented separately. Use Inspector for the dev-only runtime helpers and the Vite integration export.