Defining Stores
Stately supports two primary patterns for defining stores through the defineStore() function. Both provide the same public API, allowing you to choose the style that best fits your project's needs.
Option Stores
Option stores organize state, getters, and actions into a single configuration object. This is the closest equivalent to the standard Pinia style.
import { defineStore } from '@selfagency/stately';
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as string[] }),
getters: {
itemCount(state) {
return state.items.length;
}
},
actions: {
addItem(item: string) {
this.items.push(item);
}
}
});Option-store state() must return a plain object at runtime. Stately rejects the most common non-plain shapes up front for option stores (arrays, Date, Map, Set, promises, and similar values), but the runtime still performs the definitive prototype check when the store is created.
If you want to return a class instance or another custom prototype, use a setup store instead.
Setup Stores
Setup stores allow you to compose reactive state using Svelte 5 runes directly. This is useful when you want to utilize custom logic or share typed plugin configurations within the store definition.
import { defineStore } from '@selfagency/stately';
export const usePreferencesStore = defineStore('preferences', {
setup: () => ({
theme: 'light' as 'light' | 'dark',
compact: false,
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
},
setCompact(value: boolean) {
this.compact = value;
}
})
});Getter / setter behavior (snapshot semantics)
When a setup store property has both a getter and a setter (get + set), the getter is called once to capture the initial value. After that, the property behaves as a regular reactive state field — the getter is never re-evaluated, and subsequent writes route through the mutation pipeline.
This means:
- The getter is a snapshot. If the getter computes a value from external state or performs side effects, those run only once during setup.
- Only getter+setter pairs are snapshotted. Read-only getters (without a setter) remain live
$derived-style computed values and are re-evaluated on every read. - Persistence serializes the reactive value, not the getter. When persisting or syncing state, the snapshot value (not the getter) is written to storage.
This is by design: it guarantees that persisted and synced state remains a plain data structure with no hidden getter evaluation, and it keeps the reactive core predictable. If you need a live computed value, define it as a read-only getter (no setter).
Setup stores can also return class instances. Stately resolves members from both the instance properties and the prototype chain.
class CounterStore {
count = 0;
get doubleCount() {
return this.count * 2;
}
increment() {
this.count += 1;
}
}
export const useCounterStore = defineStore('counter', {
setup: () => new CounterStore()
});Store API & Helpers
Every store instance provides a consistent set of built-in helpers:
$id: The unique identifier of the store.$state: Direct access to the state object (excludes action methods for setup stores).$patch(): Apply partial updates or grouped mutations (typed to match$state).$reset(): Revert the store to its initial state.$subscribe(): Watch for state changes.$onAction(): Intercept or observe action execution.$dispose(): Stop reactivity and clean up listeners.
Usage Examples
const unsubscribe = counter.$subscribe((mutation, state) => {
console.log(mutation.type, state.count);
});
// Patch with an object
counter.$patch({ count: 3 });
// Patch with a function for grouped mutations
counter.$patch((state) => {
state.count += 1;
});
counter.$reset();
unsubscribe();Stately stores also implement the Svelte store contract:
subscribe()emits the full state on every change.set()replaces the current state snapshot.
Use storeToRefs() when you need to destructure properties from a store while maintaining reactivity.
const { count, doubleCount } = storeToRefs(counter);The returned refs preserve the concrete property types from the source store.
Selective Subscriptions
By default, $subscribe() observes every mutation. You can optimize this by providing a selector to only fire the callback when a specific slice of state changes.
const unsubscribe = counter.$subscribe(
(_mutation, state) => {
console.log('count changed to', state.count);
},
{
detached: true,
select: (state) => state.count
}
);You can also provide a custom equalityFn for complex data types:
store.$subscribe(callback, {
select: (state) => state.items,
equalityFn: (prev, next) => prev.length === next.length && prev.every((v, i) => v === next[i])
});Action Hooks and Guards
$onAction() allows you to react to actions and even intercept them before they run using the before() hook.
store.$onAction(({ args, before }) => {
before(() => {
const amount = args[0] as number;
if (amount > 10) {
return false; // Cancels the action
}
});
});- Returning
falsefrom abefore()guard cancels the action. - Cancelled actions return
undefinedand do not triggerafter()oronError()hooks.
Type behavior differences
For option stores, Stately infers $state and $patch() directly from the object returned by state(). Getters and actions live alongside state on the store instance but are excluded from $state and $patch() by design.
For setup stores, the setup function returns a single object that may mix data properties and methods. Stately automatically filters action functions out of $state and $patch() so they only expose data properties. This means you can safely read and patch state without actions leaking into the type:
const prefs = usePreferencesStore(manager);
// $state only contains data properties — no toggleTheme or setCompact
prefs.$state; // { theme: 'light' | 'dark'; compact: boolean }
// $patch matches $state
prefs.$patch({ theme: 'dark' });Choosing a Store Style
- Option Stores: Best for simple state models where clear separation of state, getters, and actions is preferred.
- Setup Stores: Best for complex logic, direct use of Svelte runes, or stores that require specific plugin configurations.