mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
af9ee1e4c0
- Per-channel transforms: aggregate (sum/mean/median/min/max), quantitative bin, and temporal timeUnit granularity; bin and aggregate are mutually exclusive. A field-less "Count of records" measure (Voyager's count(*)). - Chart-level sort (rank a categorical axis by its measure) and stacking (zero / 100% normalize), each shown only when it applies. - Field type is a fixed N|O|Q|T segmented control with the column's invalid types disabled; SegmentedControl gains APG-correct disabled options. - A crowded-category-axis warning (a raw measure drawing one mark per row over a large dataset) and a disabled-Create hint (says why it's disabled). - Drop the Create success toast — the new snippet is immediately visible. - Docs: spec §06, research-doc §8 backlog (incl. the cardinality/extent profiling TODO), architecture 01 (stable-selector rule) and 05 (builder-local preview), and a profiling breadcrumb.
486 lines
18 KiB
Markdown
486 lines
18 KiB
Markdown
# 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<State>()` 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' | 'settings' | '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<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).
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```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('settings'); // 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.
|
|
- **`useSettingsStore`** — user preferences (editor options, render debounce,
|
|
date format, theme); mirrors what gets persisted to `localStorage`.
|
|
|
|
### 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.).
|
|
|
|
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](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<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 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 (
|
|
<ul>
|
|
{snippets.map((s) => (
|
|
<li key={s.id} aria-current={s.id === activeSnippetId} onClick={() => select(s.id)}>
|
|
{s.name}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
remove(s.id);
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
```
|
|
|
|
> 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/main.tsx
|
|
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<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.
|
|
|
|
```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 (
|
|
<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; selector-derived values recompute on their own.
|
|
|
|
```ts
|
|
reset: () => set({ snippets: [], activeSnippetId: null, draftSpec: '' });
|
|
```
|
|
|
|
---
|
|
|
|
## 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`,
|
|
`useSettingsStore`); 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.
|
|
- 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.
|