Managing Stores
This guide is about the decisions you make around stores: how many to have, how they talk to each other, where they live, and how they get cleaned up.
Organisation
Start with one store
For a small app, one store is fine and easier to reason about:
import { defineStore } from '@quantajs/core';
export const useAppStore = defineStore('app', {
state: () => ({
user: null as User | null,
todos: [] as Todo[],
settings: { theme: 'light', notifications: true },
}),
getters: {
isAuthenticated: (s) => !!s.user,
completedTodos: (s) => s.todos.filter((t) => t.done),
},
actions: {
addTodo(text: string) {
this.todos.push({ id: crypto.randomUUID(), text, done: false });
},
toggleTheme() {
this.settings.theme = this.settings.theme === 'light' ? 'dark' : 'light';
},
},
});
Split by domain as it grows
Once a store has three distinct concerns, split it. One file per store, one domain per store:
stores/
├── user.ts
├── todos.ts
├── settings.ts
└── index.ts
// stores/todos.ts
import { defineStore } from '@quantajs/core';
export const useTodosStore = defineStore('todos', {
state: () => ({
todos: [] as Todo[],
filter: 'all' as 'all' | 'active' | 'completed',
}),
getters: {
filtered: (s) => {
if (s.filter === 'active') return s.todos.filter((t) => !t.done);
if (s.filter === 'completed') return s.todos.filter((t) => t.done);
return s.todos;
},
completedCount: (s) => s.todos.filter((t) => t.done).length,
},
actions: {
async fetch() {
const res = await fetch('/api/todos', { signal: this.$signal });
this.todos = await res.json();
},
add(text: string) {
this.todos.push({ id: crypto.randomUUID(), text, done: false });
},
toggle(id: string) {
const todo = this.todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
},
},
});
Note what is not there: no isLoading, no error, no reassignment after
push. todos.fetch.pending and todos.fetch.error already exist, and
in-place mutation is tracked.
Derive, don't synchronise
The most common state-management bug is a derived field that drifts out of sync with its source. If a value can be computed, make it a getter:
// ❌ `total` has to be updated by hand at every mutation site.
state: () => ({ items: [], total: 0 }),
actions: {
add(item) { this.items.push(item); this.updateTotal(); },
remove(id) { this.items = this.items.filter(i => i.id !== id); this.updateTotal(); },
updateTotal() { this.total = this.items.reduce((n, i) => n + i.price, 0); },
}
// ✅ `total` cannot be wrong.
state: () => ({ items: [] }),
getters: { total: (s) => s.items.reduce((n, i) => n + i.price, 0) },
actions: {
add(item) { this.items.push(item); },
remove(id) { this.items = this.items.filter(i => i.id !== id); },
}
Getters are cached and only recompute when something they actually read has changed, so this is faster as well as more correct.
Cross-store communication
A definition is a function, so one store can resolve another. Two rules keep this from becoming a tangle.
Resolve inside the action, not at module scope. A module-scope resolution would bind to whichever container happened to be ambient when the module first loaded — wrong under SSR and wrong in tests.
// stores/user.ts
import { defineStore } from '@quantajs/core';
import { useTodosStore } from './todos';
export const useUserStore = defineStore('user', {
state: () => ({ profile: null as User | null }),
actions: {
async login(credentials: Credentials) {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials),
signal: this.$signal,
});
this.profile = await res.json();
// Resolved here, at call time.
await useTodosStore().fetch();
},
logout() {
this.profile = null;
useTodosStore().clear();
},
},
});
Cross-store calls and containers:
Calling useTodosStore() with no argument resolves against the ambient
container. In a browser that is what you want. On a server it is not — a
cross-store call from a request-scoped store would reach into the shared
ambient container.
If a store participates in SSR, pass the container through explicitly, or keep the coordination in the caller rather than inside the store.
Keep the dependency one-directional. If user calls todos and todos
calls user, extract the shared piece or move the coordination up into the
component or route that owns both.
Store lifecycle
Initialisation
Prefer built-in persistence over hand-written
localStorage code:
import { defineStore, LocalStorageAdapter } from '@quantajs/core';
export const useSettingsStore = defineStore('settings', {
state: () => ({ theme: 'light', language: 'en' }),
persist: {
adapter: new LocalStorageAdapter('settings'),
debounceMs: 300,
},
});
// If you need restored state before rendering:
await useSettingsStore().$hydrated;
The adapter degrades to a no-op on the server, so this file is safe to import in server code.
Teardown
Dispose the container, not the stores one by one — one call releases every store, watcher, effect and persistence subscription in it:
container.dispose();
For a single store, store.$destroy() does the same for just that one.
In tests, destroyAllStores() resets the ambient container in one call.
Scoping
Three levels, from widest to narrowest:
| Scope | How | When |
|---|---|---|
| App-wide | call the definition, or one <QuantaProvider> | most stores |
| Per request | createContainer() per request | SSR — see the SSR guide |
| Per component instance | useLocalStore(definition) | a wizard, a modal, a draft editor |
function Wizard() {
// Two <Wizard />s never share a step.
const wizard = useLocalStore(useWizardStore);
return <p>step {wizard.step}/3</p>;
}
Performance
Subscribe to less
// Re-renders on any change to the store.
const todos = useQuanta(useTodosStore);
// Re-renders only when what the selector reads changes.
const filtered = useQuantaValue(useTodosStore, (s) => s.filtered);
// Never re-renders — dispatch only.
const todos = useQuantaActions(useTodosStore);
For a selector returning a fresh object, pass a comparator:
import { shallow } from '@quantajs/react';
const stats = useQuantaValue(
useTodosStore,
(s) => ({ completed: s.completedCount, total: s.todos.length }),
{ equalityFn: shallow },
);
Batch related writes
Several writes inside one action or one $patch already coalesce into a single
notification. Outside those, use batchEffects:
import { batchEffects } from '@quantajs/core';
batchEffects(() => {
store.a = 1;
store.b = 2;
store.items.push(x);
});
// Subscribers and effects run once.
store.$patch is the store-scoped version:
todos.$patch({ filter: 'active' });
todos.$patch((state) => {
state.todos.push(item);
state.filter = 'all';
});
Load lazily
actions: {
async loadProfile() {
if (this.profile || this.loadProfile.pending) return;
const res = await fetch(`/api/users/${this.user.id}/profile`, {
signal: this.$signal,
});
this.profile = await res.json();
},
}
Checking this.loadProfile.pending deduplicates concurrent calls without any
extra state.
Error handling
Every action already records its own failure, so a per-store error field is usually redundant:
{todos.fetch.error && <Banner>{todos.fetch.error.message}</Banner>}
error is cleared at the start of every call, so a successful retry clears the
previous failure on its own. See Async Actions.
For failures you want to aggregate — a toast queue, an error-reporting sink — a dedicated store still makes sense:
export const useErrorsStore = defineStore('errors', {
state: () => ({ entries: [] as ErrorEntry[] }),
actions: {
report(error: Error, context = '') {
this.entries.push({
id: crypto.randomUUID(),
message: error.message,
context,
at: Date.now(),
});
},
dismiss(id: string) {
this.entries = this.entries.filter((e) => e.id !== id);
},
},
});
Testing
Give each test its own container. State cannot leak between tests, and
dispose() tears down watchers and persistence so nothing fires during the
next one.
import { createContainer } from '@quantajs/core';
import { beforeEach, afterEach, expect, test } from 'vitest';
import { useTodosStore } from '../stores/todos';
let container: ReturnType<typeof createContainer>;
beforeEach(() => {
container = createContainer();
});
afterEach(() => {
container.dispose();
});
test('adding a todo', () => {
const todos = useTodosStore(container);
todos.add('write tests');
expect(todos.todos).toHaveLength(1);
});
test('starts empty', () => {
// A fresh container — the previous test's todo is not here.
expect(useTodosStore(container).todos).toHaveLength(0);
});
test('records a fetch failure', async () => {
const todos = useTodosStore(container);
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'));
await expect(todos.fetch()).rejects.toThrow('offline');
expect(todos.fetch.pending).toBe(false);
expect(todos.fetch.error?.message).toBe('offline');
});
If you prefer the ambient container, destroyAllStores() in afterEach resets
it.
DevTools
Opt in explicitly — as of 2.1, DevTools never attaches to window on its own:
import { enableDevTools } from '@quantajs/core';
if (process.env.NODE_ENV === 'development') {
enableDevTools({ redact: ['user.token'] });
}
See the DevTools guide.