# 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/compat` — **not** 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](08-vega-editor-techniques.md)) then ports directly rather than through `preact/compat`. --- ## 1. The Primitives A store is a module that calls `create()` once and exports the resulting hook. The state object holds both **data fields** and **action functions**. ```ts // src/app/stores/AppStore.ts import { create } from 'zustand'; import type { UiTheme } from '@core/theme'; // defined in core; charts key off it too export type ModalName = 'datasets' | 'about' | 'donate' | 'chartBuilder' | 'extract'; 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((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). ```tsx import { useAppStore } from '../stores/AppStore'; export function ThemeBadge() { const theme = useAppStore((s) => s.uiTheme); // re-renders only when uiTheme changes return {theme}; } ``` 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: ```tsx 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: ```tsx 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: ```ts 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. ```ts // 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: ```ts // 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. --- ## 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](10-interaction-and-feedback.md) §5. Each pairs its store with a thin **imperative trigger** exported alongside the hook — `confirm(opts): Promise` 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](10-interaction-and-feedback.md) §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. --- ## 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. ```ts // 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((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 and calls actions: ```tsx import { useShallow } from 'zustand/react/shallow'; import { useSnippetStore } from '../stores/SnippetStore'; export function SnippetList() { const { snippets, activeSnippetId } = useSnippetStore( useShallow((s) => ({ snippets: s.snippets, activeSnippetId: s.activeSnippetId })), ); const select = useSnippetStore((s) => s.select); const remove = useSnippetStore((s) => s.remove); return (
    {snippets.map((s) => (
  • select(s.id)}> {s.name}
  • ))}
); } ``` > 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. ```ts // 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. --- ## 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) ```ts // 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. ### Debounced auto-save of the draft spec 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: ```ts // src/app/orchestration/persistence.ts import { useSnippetStore } from '../stores/SnippetStore'; import { saveSnippet } from '../infrastructure/snippet-store'; // IndexedDB adapter export function wireDraftAutoSave(): void { let timer: ReturnType | 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. ```tsx // 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 ( ); } ``` 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. `