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:

ScopeHowWhen
App-widecall the definition, or one <QuantaProvider>most stores
Per requestcreateContainer() per requestSSR — see the SSR guide
Per component instanceuseLocalStore(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 },
);

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.

Learn More