mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Initial scaffold: spec, architecture playbook, and M0 skeleton
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 })),
|
||||
);
|
||||
```
|
||||
|
||||
### 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`.
|
||||
|
||||
### 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 === 'experimental' ? 'light' : 'experimental')}>
|
||||
{theme === 'experimental' ? '🌙' : '☀️'}
|
||||
</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.
|
||||
Reference in New Issue
Block a user