Files

23 KiB

State Management & Stores

How Astrolabe holds and shares application state. The whole app is built on Zustand: small, standalone stores created with create(), each exposing state fields and the actions that mutate them. Components subscribe to the exact slices they read; non-component code (services, infrastructure, orchestration) reads and writes the same stores directly. This document defines how we use Zustand, where state lives, and the rules that keep state predictable as the app grows.

Why Zustand: it is idiomatic React (just a hook), it has a first-class outside-React API (getState/setState/subscribe) that fits our "logic lives in core/services, not components" architecture, and it carries no build-time magic. The principles below (one source of truth, derive-don't-duplicate, actions outside components, thin components) are the durable part — they would survive a change of library.

Lineage (why React + Zustand). The UI began on Preact + @preact/signals and migrated to React + Zustand at M0, before feature work. The driver was React-ecosystem friction — real-React-only libraries not cooperating with preact/compatnot the signals model. Switching framework while nothing was implemented yet was also the one cheap moment to pick the lowest-migration-risk state library, so signals gave way to Zustand. A bonus: borrowing from the React-based vega/editor reference (see 08) then ports directly rather than through preact/compat.


1. The Primitives

A store is a module that calls create<State>() once and exports the resulting hook. The state object holds both data fields and action functions.

import { create } from 'zustand';

// ModalName is the union of the app's modal identifiers; UiTheme is defined in core.
export interface AppState {
  uiTheme: UiTheme;
  activeModal: ModalName | null;
  setTheme: (theme: UiTheme) => void;
  // Low-level primitive. High-level open/close (snapshot, URL sync, discard
  // prompt) is the modal coordinator's job — see docs/architecture/03.
  setActiveModal: (modal: ModalName | null) => void;
}

export const useAppStore = create<AppState>((set) => ({
  uiTheme: 'light',
  activeModal: null,
  setTheme: (uiTheme) => set({ uiTheme }),
  setActiveModal: (activeModal) => set({ activeModal }),
}));

Three ways to touch a store:

  • set(partial) — update state (shallow-merges). Inside actions, the only place that mutates state.
  • get() — read current state inside actions without subscribing.
  • the hook useAppStore(selector) — read state in a React component, subscribing to exactly what the selector returns.

Reading in components — always select narrowly

Call the hook with a selector that returns the smallest thing you need. The component re-renders only when that selected value changes (default Object.is comparison).

import { useAppStore } from '../stores/AppStore';

export function ThemeBadge() {
  const theme = useAppStore((s) => s.uiTheme); // re-renders only when uiTheme changes
  return <span>{theme}</span>;
}

When you select multiple fields or a fresh object/array, wrap the selector in useShallow so a new-but-equal result doesn't cause an extra render:

import { useShallow } from 'zustand/react/shallow';

const { activeModal, uiTheme } = useAppStore(
  useShallow((s) => ({ activeModal: s.activeModal, uiTheme: s.uiTheme })),
);

useShallow only helps when the elements are stable. It shallow-compares the result — array elements (or object values) by Object.is. A selector that computes a fresh collection of fresh objects each call (e.g. useShallow((s) => buildWarnings(s.config))) defeats it: every element is a new reference, so the result never compares equal, useSyncExternalStore re-renders forever, and React throws "Maximum update depth exceeded" (a white screen). A selector must return a primitive or a stored reference — never a freshly built array/object. Derive computed collections in the component with useMemo over a stable slice instead:

const config = useChartBuilderStore((s) => s.config); // stored ref, stable between updates
const warnings = useMemo(() => builderWarnings(config), [config]); // recompute only on change

This is a render-time loop, so core/store unit tests stay green and miss it. A bare react-dom/client + react's act mount test catches it with no test-library dependency — mount the component in the looping config and assert it doesn't throw (prove the guard by reverting the fix first). See ChartBuilderModal.test.tsx.

Reading/writing outside components

Services, orchestration, infrastructure, and tests use the store object directly — no React involved. This is the property that lets our logic live outside components:

openModal('datasets'); // via the modal coordinator (doc 03)
const theme = useAppStore.getState().uiTheme; // snapshot read
const unsub = useAppStore.subscribe((s, prev) => {
  /* react to changes */
});

Rule: in components, select narrowly (and useShallow for object/array selections). Outside components, use getState() for a snapshot, subscribe() to react.


2. One Source of Truth per Fact — Derive, Don't Duplicate

Every fact lives in exactly one state field. Anything that can be calculated from other state is computed in a selector at read time, never stored as a second field you keep in sync by hand.

The failure mode this avoids: two fields that must agree (snippets and snippetCount, or activeSnippetId and activeSnippet) drift apart because one update path forgets the other. If the derived value is computed from the source on read, drift is structurally impossible.

// State holds only the sources:
//   snippets: Snippet[]
//   activeSnippetId: string | null

// Derive in the component's selector — not a stored field:
const activeSnippet = useSnippetStore(
  (s) => s.snippets.find((x) => x.id === s.activeSnippetId) ?? null,
);
const snippetCount = useSnippetStore((s) => s.snippets.length);

For a derivation that is expensive or reused in many places, expose it as a selector function (memoize if profiling shows it matters) rather than caching it into state:

// src/app/stores/snippet-selectors.ts
export const selectActiveSnippet = (s: SnippetState) =>
  s.snippets.find((x) => x.id === s.activeSnippetId) ?? null;

// in a component:
const active = useSnippetStore(selectActiveSnippet);

Rule: if you can compute it, do not store it. Add a new state field only for a value that is input the app receives, not output it derives.

Editing buffers — the sanctioned duplication, and its sync rule

A text field with debounced auto-save (the metadata panel's Name/Comment, the editor buffer) legitimately mirrors a store fact into local component state: the local copy is the user's in-progress text, the store holds the saved value. This duplication carries an obligation the moment the store fact has another writer (publish's content-derived renaming, import, any store-side mutation): the component must adopt a store change it didn't make, or its debounced save will write the stale local copy back — silently undoing the other writer. The pattern (see SnippetLibrary's SnippetMeta): track the last store value seen in a ref; when the store value changes, adopt it into local state unless the user has diverged (local ≠ previous store value) — in-progress typing always wins. Keying the component by entity id handles switching entities; this rule handles the same entity changing underneath.


3. Where State Lives: Central vs. Per-Feature Stores

Each store is its own create() module. We split by concern, not by component tree.

Per-feature stores

Each cohesive feature owns a store holding its durable domain state.

  • useSnippetStore — the snippet library: snippets, activeSnippetId, the working draftSpec, and its actions.
  • useDatasetStore — loaded datasets, the active dataset, inferred fields.
  • useUserSettingsStore — the managed user preferences applied live as saved (editor options, render debounce, date format). Its per-cluster setters are called by the per-pane settings popovers; the editor/preview/library read saved.*. There is no draft/Apply — settings are distributed and commit on change (spec §07). UI theme and preview fit mode are NOT here — they're cross-cutting and live in useAppStore (header toggle, Fit control); all three persist to the one astrolabe:settings record via slice writers (arch 02 §5).

Global overlay stores (imperative trigger)

Some surfaces are summoned from anywhere — including non-React code — and own only ephemeral request state, not durable data:

  • useConfirmStore — the blocking confirm dialog (the window.confirm replacement).
  • useNotificationStore — non-blocking toasts (failed saves, etc.).
  • useSettingsPopoverStore — the single-open registry for all pane-header disclosures (the per-pane settings clusters and the per-chart Export control), keyed by id so at most one is open at once; its imperative openSettingsPopover(id) lets the Cmd/Ctrl+, shortcut open the editor cluster. Header disclosures share this registry rather than each carrying their own open-state. The disclosure widget contract (trigger + non-modal popover, not an ARIA menu; Esc/focus rules) is 10 · Interaction & Feedback §5.

Each pairs its store with a thin imperative trigger exported alongside the hook — confirm(opts): Promise<boolean> and notify(opts): string — so orchestration/services can raise one without a hook: export const notify = (o) => useNotificationStore.getState().notify(o). Components subscribe to the store to render it; everyone else calls the function. (Why a toast at all, and which channel for which message: 10 · Interaction & Feedback §1.)

The central useAppStore

useAppStore holds only cross-cutting, ephemeral UI state that no single feature owns — which modal is open, the runtime theme, transient render flags.

How to decide

Put it in a feature store when… Put it in useAppStore when…
It's domain data (snippets, datasets, specs) It's transient UI chrome (open modal, theme)
It outlives a single interaction It belongs to no single feature
It gets persisted Multiple unrelated features read/write it

Rule: keep useAppStore small. When a chunk of it only ever serves one feature, that's the signal to extract a feature store. A bloated central store is the thing this split exists to prevent.

Rule: a store earns its place by decoupling producers from consumers — a fact belongs in one when more than one component reads it, or when many sites produce it for one surface to consume (the imperative notify() / confirm() overlay stores). When a single component is both the only producer and the only consumer, the fact is that component's local useState, not a store — a store there decouples nothing, and is the shape to fold back.


4. Actions: Mutations Live in the Store, Not Components

Components render and dispatch; they do not contain mutation logic. Every state change goes through a named action defined on the store (via set/get). Multi-step logic that coordinates several stores or touches infrastructure can live in a src/app/services/* module that calls store actions.

// src/app/stores/SnippetStore.ts
import { create } from 'zustand';
import type { Snippet } from '@core/snippet';

interface SnippetState {
  snippets: Snippet[];
  activeSnippetId: string | null;
  draftSpec: string; // Monaco editor buffer (Vega-Lite JSON)

  create: (name: string) => string;
  select: (id: string) => void;
  remove: (id: string) => void;
  updateDraft: (spec: string) => void;
  reset: () => void;
}

export const useSnippetStore = create<SnippetState>((set, get) => ({
  snippets: [],
  activeSnippetId: null,
  draftSpec: '',

  create: (name) => {
    const snippet: Snippet = { id: crypto.randomUUID(), name, spec: '{}' };
    set((s) => ({ snippets: [...s.snippets, snippet] }));
    get().select(snippet.id);
    return snippet.id;
  },

  select: (id) =>
    set((s) => ({
      activeSnippetId: id,
      draftSpec: s.snippets.find((x) => x.id === id)?.spec ?? '{}',
    })),

  remove: (id) =>
    set((s) => {
      const snippets = s.snippets.filter((x) => x.id !== id);
      const activeSnippetId =
        s.activeSnippetId === id ? (snippets[0]?.id ?? null) : s.activeSnippetId;
      return { snippets, activeSnippetId };
    }),

  updateDraft: (draftSpec) => set({ draftSpec }),

  reset: () => set({ snippets: [], activeSnippetId: null, draftSpec: '' }),
}));

The component is thin — it selects state narrowly and wires events to actions, with no mutation logic of its own:

export function SnippetList() {
  const { snippets, activeSnippetId } = useSnippetStore(
    useShallow((s) => ({ snippets: s.snippets, activeSnippetId: s.activeSnippetId })),
  );
  const select = useSnippetStore((s) => s.select); // stable identity — select actions individually
  const remove = useSnippetStore((s) => s.remove);
  // render: one row per snippet, each calling select(id) / remove(id) on the events.
}

Note: action identities are stable, so selecting them (s.select) never causes re-renders — select actions individually rather than bundling them into a useShallow object.

Why mutations live in the store

  • Testable without a DOM. Actions are plain functions over state. A Vitest test calls useStore.getState().create('x') and asserts on getState() — no rendering, no React.
  • One place to change behavior. "Deleting the active snippet falls back to the first remaining one" is a rule that lives in remove, not scattered across every delete button.
  • Readable components. A component that only wires events to named actions reads like a description of the UI, not a tangle of state juggling.
// SnippetStore.test.ts — no browser needed
import { useSnippetStore } from './SnippetStore';

beforeEach(() => useSnippetStore.getState().reset());

test('deleting the active snippet selects the next one', () => {
  const store = useSnippetStore.getState();
  const a = store.create('A');
  const b = store.create('B');
  store.select(a);
  store.remove(a);
  expect(useSnippetStore.getState().activeSnippetId).toBe(b);
});

Rule: no setState calls inside component bodies for shared state — call an action. Local, throwaway UI state (a dropdown's open flag) may stay in component useState; anything another component reads belongs in a store behind an action.

Change detection: reference identity, not serialization

Because every action replaces a state object via spread (never mutates it), "has this changed since X" is reference identity against the object captured at X. The Chart Builder's dataset switch keeps the exact config init produced (initialConfig) and asks config === initialConfig to tell an untouched opening default from built-on work. Field-level comparison against the source record (the Theme Builder's draft-dirty selector) is the equivalent for forms seeded from a saved record.

Rule: never detect change by serializing and comparing (JSON.stringify(a) === JSON.stringify(b)) — it silently depends on key order, costs proportionally to state size, and a store that replaces objects immutably already has a cheaper, exact signal. The one sanctioned serialization is the modal coordinator's unsaved-change snapshot (architecture 03), where a cross-store, store-agnostic baseline is the point.


5. Effects: Persistence and External Sync

Cross-cutting reactions — persisting state, mirroring the theme onto the document, pushing the draft into Vega for rendering — are wired once at app startup with store.subscribe(...), in the orchestration/startup layer, not in components. Subscribers read state and write to src/app/infrastructure/ adapters (IndexedDB, localStorage, URL hash).

Theme → document (the minimal example, already wired)

// src/app/orchestration/theme.ts (wired from main.tsx at startup)
const applyTheme = (t: string) => {
  document.documentElement.dataset.theme = t;
};
applyTheme(useAppStore.getState().uiTheme);
useAppStore.subscribe((s, prev) => {
  if (s.uiTheme !== prev.uiTheme) applyTheme(s.uiTheme);
});

The store stays DOM-free; the adapter (the applyTheme subscriber) lives at the edge.

Adding a persisted UI preference (the established chain)

A small global preference (preview fit mode, chart theme) follows one chain — five touch points, in order:

  1. core/settings.ts — field on UserSettings + defaultSettings() + loadSettings validation (an unknown stored value falls back to the default, never breaks the app).
  2. infrastructure/settings-store.ts — slice loader/saver pair using the per-slice write-through merge (doc 02 §5), so other writers of the shared record survive.
  3. stores/AppStore.ts — field + setter (the store stays browser-free).
  4. orchestration/preferences.tsinitX() hydrates the store from the adapter; wireX() subscribes store → adapter.
  5. main.tsx — call both before createRoot().render (the store is the single source of truth from first paint).

The UI control only calls the AppStore setter; persistence follows from the subscriber.

The Monaco editor writes every keystroke into draftSpec. We do not persist on every keystroke. A startup subscriber observes the draft and debounces the expensive work:

// src/app/orchestration/snippet-persistence.ts
import { useSnippetStore } from '../stores/SnippetStore';
import { saveSnippet } from '../infrastructure/snippet-store'; // IndexedDB adapter

export function wireDraftAutoSave(): void {
  let timer: ReturnType<typeof setTimeout> | undefined;

  useSnippetStore.subscribe((s, prev) => {
    if (s.draftSpec === prev.draftSpec) return; // only react to draft edits
    const id = s.activeSnippetId;
    if (!id) return;

    clearTimeout(timer);
    const spec = s.draftSpec;
    timer = setTimeout(() => {
      useSnippetStore.setState((cur) => ({
        snippets: cur.snippets.map((x) => (x.id === id ? { ...x, spec } : x)),
      }));
      void saveSnippet(id, spec);
    }, 400);
  });
}

For selector-based subscriptions (subscribe(selector, listener) with an equality function) add the subscribeWithSelector middleware to the store. Plain subscribe((state, prev) => …) as above is enough for most wiring.

Rule: components never touch infrastructure adapters directly. Reads/writes to IndexedDB, localStorage, and the URL hash happen in startup subscribers or actions, so the persistence story is in one place and the UI stays pure.


6. Reading State: Import the Store, Don't Thread It

Because stores are singletons importable anywhere, a deep leaf component reads the state it needs directly instead of receiving it through five layers of props.

// Good: a deeply nested toggle reads + flips the theme itself.
import { useAppStore } from '../stores/AppStore';

export function ThemeToggle() {
  const theme = useAppStore((s) => s.uiTheme);
  const setTheme = useAppStore((s) => s.setTheme);
  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      {theme === 'dark' ? '🌙' : '☀️'}
    </button>
  );
}

This is the right default for global/shared state. Threading theme and onThemeChange through Layout → Header → Toolbar → ThemeToggle adds noise and couples every intermediate component to data it doesn't use.

When to thread props instead

  • The value is presentational input, not shared app state. <Button variant="primary"> takes variant as a prop; it should not know about any store.
  • The component is meant to be reusable / store-agnostic (design-system components, list-item renderers given their item via prop).
  • A parent supplies per-instance data, e.g. <SnippetRow snippet={s} /> inside a .map() — the row gets its snippet by prop but still calls useSnippetStore.getState().remove(...) (or a selected action) for mutations.

Rule of thumb: shared app state → select it from the store at the point of use. Per-instance or presentational data → pass it as a prop. Passing global state down as props is the anti-pattern to avoid.


7. Resetting State

Each store exposes a reset() action that returns its fields to initial values (used on "new workspace", sign-out, or test teardown). Because every fact is a single source field with no hand-maintained duplicates, reset is a flat set(...) of the initial values (as in SnippetStore above); selector-derived values recompute on their own.


Rules Summary

Do

  • Keep one state field per fact; derive everything else in selectors, not stored fields.
  • In components, select narrowly; use useShallow for object/array selections. Outside components, use getState() / subscribe().
  • Split durable domain state into feature stores (useSnippetStore, useDatasetStore, useUserSettingsStore); keep useAppStore for thin cross-cutting UI state.
  • Put every shared-state mutation behind a named action on the store so it's testable without a DOM (getState().action()).
  • Do persistence and external sync (IndexedDB, localStorage, URL hash, theme) in startup subscribe listeners via infrastructure/ adapters.
  • Debounce expensive reactions (auto-save, re-render) inside the subscriber.
  • Advance a snippet's modified on every save the library sorts by — draft auto-save, inline name/comment edits, publish, revert, rename-propagation — so Modified-descending keeps the just-touched snippet on top (spec §02 → Sort).
  • Bump SnippetStore.bufferEpoch only on a programmatic buffer load (select / create / duplicate / revert / hydrate) — it is the "reload the editor, this isn't a keystroke" signal consumed by both the Monaco buffer and the preview's immediate-render path (arch 05 §5). Metadata edits (name/comment) advance modified but must not bump it — they aren't in the spec buffer.
  • Import singleton store hooks directly in the leaves that need shared state.

Don't

  • Don't store derived values as their own fields and sync them by hand.
  • Don't call setState for shared state inside component render bodies — call an action.
  • Don't select broad objects without useShallow (causes needless re-renders).
  • Don't let useAppStore accumulate feature-specific state; extract a store.
  • Don't touch IndexedDB/localStorage/URL adapters from components.
  • Don't thread global state down through props; don't pass per-instance or presentational data via store imports.
  • Don't detect change by serialize-and-compare; immutable replacement makes reference identity the exact, cheap signal (§4 → Change detection).