The store module provides Pinia/Vuex-style state management built entirely on bQuery's reactive signals.
import { createStore } from '@bquery/bquery/store';
import { effect } from '@bquery/bquery/reactive';Basic Store
const counterStore = createStore({
id: 'counter',
state: () => ({
count: 0,
step: 1,
}),
getters: {
doubled: (state) => state.count * 2,
isPositive: (state) => state.count > 0,
},
actions: {
increment() {
this.count += this.step;
},
decrement() {
this.count -= this.step;
},
setStep(newStep: number) {
this.step = newStep;
},
},
});
// Use the store — state, getter and action types are all inferred
counterStore.increment();
console.log(counterStore.count); // 1
console.log(counterStore.doubled); // 2Store Factory (defineStore)
Create a reusable store factory (Pinia-style) that lazily instantiates the store:
import { defineStore } from '@bquery/bquery/store';
const useCounter = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++;
},
},
});
const counter = useCounter();
counter.increment();Reactive Updates
Store state is fully reactive:
effect(() => {
console.log('Count changed:', counterStore.count);
});
counterStore.increment(); // Logs: "Count changed: 1"State
State is defined via a factory function that returns the initial values.
Shallow Reactivity
Store state uses shallow reactivity. Only top-level property assignments trigger reactive updates. Mutating nested objects directly (e.g., store.nested.prop = value) will NOT notify subscribers. Always replace the entire nested object or use $patch.
const userStore = createStore({
id: 'user',
state: () => ({
name: 'Anonymous',
email: null as string | null,
preferences: {
theme: 'dark',
notifications: true,
},
}),
});
// Read state
console.log(userStore.name);
// Update state (top-level properties are reactive)
userStore.name = 'Alice';
// ⚠️ Nested mutations do NOT trigger reactive updates:
// userStore.preferences.theme = 'light'; // Won't notify subscribers!
// ✅ Replace the entire nested object instead:
userStore.preferences = { ...userStore.preferences, theme: 'light' };
// ✅ Or use $patch for multiple updates:
userStore.$patch((state) => {
state.preferences = { ...state.preferences, theme: 'light' };
});Getters
Getters derive computed values from state. Each getter receives the typed state, and its return type becomes the type of the getter on the store:
const cartStore = createStore({
id: 'cart',
state: () => ({
items: [] as { price: number; qty: number }[],
discount: 0,
}),
getters: {
subtotal: (state) => state.items.reduce((sum, item) => sum + item.price * item.qty, 0),
itemCount: (state) => state.items.reduce((sum, item) => sum + item.qty, 0),
},
});
console.log(cartStore.subtotal); // number
console.log(cartStore.itemCount); // numberA getter can read other getters through its second argument. TypeScript cannot infer a getter's type from the getters themselves, so declare the state and getter types explicitly in that case (add the actions type as the third generic if the store has actions). Without them, every getter drops out of the store's type, and the error names EmptyStoreRecord (for example Property 'total' does not exist on type 'EmptyStoreRecord'):
type CartState = { items: { price: number; qty: number }[]; discount: number };
type CartGetters = { subtotal: number; total: number };
const checkoutStore = createStore<CartState, CartGetters>({
id: 'checkout',
state: () => ({ items: [], discount: 0 }),
getters: {
subtotal: (state) => state.items.reduce((sum, item) => sum + item.price * item.qty, 0),
total: (state, getters) => getters.subtotal * (1 - state.discount),
},
});Actions
Actions are methods that can modify state. Inside an action, this is typed as the store's state, getters and other actions, and each action keeps its parameter and return types on the store. Annotate action parameters — they cannot be inferred from usage. Actions support async operations:
const authStore = createStore({
id: 'auth',
state: () => ({
user: null as User | null,
loading: false,
error: null as string | null,
}),
actions: {
async login(email: string, password: string) {
this.loading = true;
this.error = null;
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
this.user = await response.json();
} catch (e) {
this.error = (e as Error).message;
} finally {
this.loading = false;
}
},
logout() {
this.user = null;
},
},
});
await authStore.login('user@example.com', 'password');Store Methods
$reset
Reset state to initial values:
counterStore.count = 100;
counterStore.$reset();
console.log(counterStore.count); // 0$patch
Update multiple properties at once:
// Object syntax
userStore.$patch({
name: 'Bob',
email: 'bob@example.com',
});
// Function syntax for nested updates
userStore.$patch((state) => {
state.preferences = {
...state.preferences,
theme: 'light',
notifications: false,
};
});Nested Objects in $patch
Even within $patch, you must replace nested objects entirely (using spread orObject.assign) to ensure reactive updates. Direct nested mutations likestate.preferences.theme = 'light' will NOT trigger subscribers.
In development mode, bQuery will warn you if it detects nested mutations that won't trigger reactive updates.
$patchDeep
For scenarios where you need deep reactivity without manually replacing objects, use $patchDeep. This method deep-clones the state before mutation, ensuring all changes trigger reactive updates:
// ✅ Nested mutations work with $patchDeep
userStore.$patchDeep((state) => {
state.preferences.theme = 'light'; // Works!
state.preferences.notifications = false; // Works!
});
// Also works with partial objects
userStore.$patchDeep({
preferences: { theme: 'dark', notifications: true },
});Performance
$patchDeep creates deep copies of your state, which has a performance cost for large objects or frequent updates. Use $patch with manual object replacement for performance-critical code.
$subscribe
React to state changes:
const unsubscribe = counterStore.$subscribe((state) => {
console.log('State changed:', state);
localStorage.setItem('counter', JSON.stringify(state));
});
// Later
unsubscribe();$state
Get a snapshot of current state:
const snapshot = counterStore.$state;
console.log(snapshot); // { count: 0, step: 1 }$id
Access the store identifier:
console.log(counterStore.$id); // 'counter'$onAction
Use $onAction() to observe action lifecycles without wrapping every action manually.
const stop = counterStore.$onAction(({ name, args, after, onError }) => {
console.log('Before action:', name, args);
after((result) => {
console.log('Resolved action:', name, result);
});
onError((error) => {
console.error('Failed action:', name, error);
});
});
stop();Register after() and onError() hooks synchronously before the callback returns. Promise-returning listeners can still report their own failures, but hooks registered after an await do not affect the current action call.
Store Registry
getStore
Retrieve an existing store by ID:
import { getStore } from '@bquery/bquery/store';
const counter = getStore<typeof counterStore>('counter');
counter?.increment();listStores
List all registered store IDs:
import { listStores } from '@bquery/bquery/store';
console.log(listStores()); // ['counter', 'user', 'auth']destroyStore
Remove a store from the registry:
import { destroyStore } from '@bquery/bquery/store';
destroyStore('counter');Persisted Store
Automatically persist to localStorage:
import { createPersistedStore } from '@bquery/bquery/store';
const settingsStore = createPersistedStore({
id: 'settings',
state: () => ({
theme: 'dark',
language: 'en',
fontSize: 14,
}),
});
// State is automatically saved to localStorage
// and restored on page reload
settingsStore.theme = 'light';Custom storage key:
const store = createPersistedStore(
{ id: 'mystore', state: () => ({ value: 0 }) },
'custom-storage-key'
);Advanced persistence options
The new object-based signature supports custom storage backends, serializers, and migrations.
const settingsStore = createPersistedStore(
{
id: 'settings',
state: () => ({ theme: 'dark', density: 'comfortable' }),
},
{
key: 'app-settings',
storage: sessionStorage,
version: 2,
serializer: {
serialize: JSON.stringify,
deserialize: JSON.parse,
},
migrate: (persisted, oldVersion) => {
if (oldVersion < 2) {
return { ...persisted, density: 'comfortable' };
}
return persisted;
},
}
);The legacy createPersistedStore(definition, 'custom-key') form still works when you only need a custom storage key.
Plugins
Extend all stores with plugins:
import { registerPlugin } from '@bquery/bquery/store';
// Logger plugin
registerPlugin(({ store, options }) => {
console.log(`Store "${options.id}" created`);
store.$subscribe((state) => {
console.log(`[${options.id}] State changed:`, state);
});
});
// Persistence plugin
registerPlugin(({ store, options }) => {
const key = `store-${options.id}`;
// Load saved state
const saved = localStorage.getItem(key);
if (saved) {
store.$patch(JSON.parse(saved));
}
// Save on changes
store.$subscribe((state) => {
localStorage.setItem(key, JSON.stringify(state));
});
});Removing plugins
Plugins are registered globally and remain active for the lifetime of the process by default. Use unregisterPlugin() to remove a single registration or clearPlugins() to drop every plugin at once. Already-created stores keep extensions that were applied before unregister; only stores created afterwards are affected.
import { clearPlugins, registerPlugin, unregisterPlugin } from '@bquery/bquery/store';
const logger = ({ store, options }) => {
store.$subscribe((state) => console.log(`[${options.id}]`, state));
};
registerPlugin(logger);
// Remove a specific plugin (returns `true` when found, `false` otherwise).
unregisterPlugin(logger);
// Drop every registration — useful in test teardown.
clearPlugins();Mapping Helpers
mapState
Map state properties for destructuring:
import { mapState } from '@bquery/bquery/store';
const { count, step } = mapState(counterStore, ['count', 'step']);
// Properties remain reactive
effect(() => {
console.log(count); // Tracks changes
});mapGetters
Map computed getters for convenient access:
import { mapGetters } from '@bquery/bquery/store';
const getters = mapGetters(counterStore, ['doubled']);
console.log(getters.doubled); // Access via properties to preserve reactivitymapActions
Map actions for easier usage:
import { mapActions } from '@bquery/bquery/store';
const { increment, decrement } = mapActions(counterStore, ['increment', 'decrement']);
// Use directly
increment();watchStore
Watch a selected slice of store state with optional deep comparison:
import { watchStore } from '@bquery/bquery/store';
const stop = watchStore(
counterStore,
(state) => state.count,
(value, previous) => {
console.log('Count changed:', value, previous);
},
{ immediate: true }
);
// Later
stop();Devtools Integration
Stores automatically register with window.__BQUERY_DEVTOOLS__:
// In browser console
window.__BQUERY_DEVTOOLS__.stores.get('counter');Devtools can subscribe to store events:
window.__BQUERY_DEVTOOLS__ = {
stores: new Map(),
onStoreCreated: (id, store) => {
console.log('Store created:', id);
},
onStateChange: (id, state) => {
console.log('State changed:', id, state);
},
};Type Reference
// S, G and A are inferred from the definition (state factory return type,
// getter return types, action methods); pass them explicitly only when needed.
type StoreDefinition<S, G, A> = {
id: string;
state: () => S;
getters?: { [K in keyof G]: (state: S, getters: G) => G[K] };
actions?: A & ThisType<S & G & A>;
};
type Store<S, G, A> = S &
G &
A & {
$id: string;
$reset: () => void;
$subscribe: (callback: (state: S) => void) => () => void;
$patch: (partial: Partial<S> | ((state: S) => void)) => void;
$patchDeep: (partial: Partial<S> | ((state: S) => void)) => void;
$state: S;
};
const useStore = defineStore(id, definition);
const stop = watchStore(store, selector, callback, {
immediate?: boolean;
deep?: boolean;
equals?: (a, b) => boolean;
});
const mapped = mapGetters(store, ['getterKey']);Pitfalls and gotchas
- Define actions as regular methods (
actions: { increment() { this.count++ } }) — arrow functions breakthisbinding. - Store types are inferred from the definition; annotate action parameters (
add(title: string) { … }). Explicit generics (createStore<State, Getters, Actions>(…)) are only needed when a getter reads other getters via its second argument — use type aliases rather than interfaces for them. - Mutations go through
thisinside actions, not via a separate state parameter. - Persistence plugins run on every action commit; filter persisted fields with
createPersistedStore(..., { serializer })or a customStorePlugin. unregisterPlugin()/clearPlugins()(1.12.0) are essential for test isolation when multiple suites register the same plugin.- Getters are pure derivations — do not mutate state inside them.
Performance notes
- Group multiple state changes inside a single action so subscribers fire once.
- Use
mapState/mapGettersto subscribe components to slices instead of the whole store.
Testing this module
@bquery/bquery/testingshipsmockStore()for ergonomic store mocks with reset helpers.- Always call
clearPlugins()inafterEachif your suite registers persistence or devtools plugins.
Related modules
- Reactive — stores are signal-backed under the hood.
- Devtools —
inspectStores()anddiffStores()for runtime inspection. - SSR —
serialize()/hydrate()for state transfer.
Version history
- 1.18.0 —
createStore(),defineStore()andcreatePersistedStore()infer state, getter and action types from the definition (actions get a typedthis); explicit generics still work. - 1.17.1 — the store's
isEqual/deepEqualno longer treat values of different kinds ({},[],Date,Map,Set,RegExp) as equal. - 1.16.0 —
deepClone(used by$patchDeep) special-cases only the genuinely dangerous__proto__key; own properties merely namedconstructor/prototypeare cloned normally again instead of being silently dropped. - 1.15.1 —
deepCloneskips prototype-pollution keys, so an own enumerable__proto__no longer reassigns the clone's prototype;$subscribenotifications iterate a snapshot, so unsubscribing during notification no longer skips the next subscriber. - 1.12.0 —
unregisterPlugin()andclearPlugins()for plugin teardown and test isolation.