|
| 1 | +# Store conventions |
| 2 | + |
| 3 | +Tooee models stateful interaction systems (command registry, surface stack, |
| 4 | +overlay stack, sequence display, nav/search) as explicit event-driven stores |
| 5 | +built on `@xstate/store`, with thin React adapters on top. This note records |
| 6 | +when to reach for a store, where store code lives, and how it is tested. |
| 7 | + |
| 8 | +Rule of thumb: **transition logic moves into store events; IO stays at the |
| 9 | +boundary.** An effect that only *repairs state after render* is a store |
| 10 | +transition in disguise. An effect that talks to a renderer, timer, stream, or |
| 11 | +process is doing its job. |
| 12 | + |
| 13 | +## When to use what |
| 14 | + |
| 15 | +| Situation | Use | |
| 16 | +|---|---| |
| 17 | +| Synchronous state updated by named events; cross-component; needs subscription/selectors (registry, surface stack, overlay stack, nav/search, sequence display) | `@xstate/store` | |
| 18 | +| Async lifecycles with real states and cancellable work (content loader) | `@xstate/store` + request-ids first; full `xstate` only if a written justification shows the statechart pays for itself | |
| 19 | +| Component-local input state that never crosses a component boundary (a filter query local to one overlay, a focus flag) | React `useState` | |
| 20 | +| Purely derived render data | `useMemo`/selectors — never an effect, never a store | |
| 21 | +| Imperative OpenTUI ref sync, subscriptions, timers, async IO | effects (or store-adjacent wrappers) — these are legitimate external synchronization and stay effects | |
| 22 | + |
| 23 | +## File layout |
| 24 | + |
| 25 | +``` |
| 26 | +packages/<pkg>/src/<domain>-store.ts # createXxxStore() factory: context type, events, pure transitions, selectors. No React imports. |
| 27 | +packages/<pkg>/src/use<Domain>.ts # React wrappers: context provider plumbing, useSelector hooks. (May be folded into an existing provider file when the provider is the only consumer.) |
| 28 | +``` |
| 29 | + |
| 30 | +- Stores are **per-provider instances** created by a factory |
| 31 | + (`createCommandStore(options)`), never module-level singletons — tests and |
| 32 | + multiple app roots need isolated instances. |
| 33 | +- Selectors are exported from the store file as named pure functions |
| 34 | + (`selectActiveModalSurface(ctx)`) so pure tests and React hooks share them. |
| 35 | +- Side effects (callbacks, mode restoration, handler invocation) leave the |
| 36 | + store via emitted events (`enqueue.emit.*` / `store.on`) or via a thin |
| 37 | + imperative wrapper that owns IO-adjacent state (timers, key buffers) and is |
| 38 | + the store's single writer. |
| 39 | + |
| 40 | +## Event typing |
| 41 | + |
| 42 | +Stray event names must be compile errors. Two equivalent, accepted ways to |
| 43 | +get there: |
| 44 | + |
| 45 | +- **Handler inference** (commands store): type each transition's `event` |
| 46 | + parameter inline and let `createStore` infer the event payload map from |
| 47 | + `on`. Works when the store emits nothing. |
| 48 | +- **Explicit closed generics** (overlay store): declare event/emitted payload |
| 49 | + map types and pass them as `createStore<TContext, TEvents, TEmitted>` |
| 50 | + generics. Required when transitions `enqueue.emit`, since v4 removed the |
| 51 | + `emits` config. |
| 52 | + |
| 53 | +Either way the payload maps must stay **closed**: never add a |
| 54 | +`[key: string]: ...` index signature to satisfy a constraint — it silently |
| 55 | +makes every `trigger.*`/`emit.*`/`send`/`on` name compile. |
| 56 | +`overlay-store.typecheck.ts` pins this with `@ts-expect-error` assertions |
| 57 | +checked by `tsc -b`. v4's `schemas` config (Standard Schema) is for runtime |
| 58 | +validation and type inference from schema libraries; do not add type-only |
| 59 | +schema objects just for event-name strictness — the generics already provide |
| 60 | +it. |
| 61 | + |
| 62 | +## Testing pattern |
| 63 | + |
| 64 | +1. **Pure transition tests first** (`test/<domain>-store.test.ts`): create |
| 65 | + store, trigger events, assert snapshots/selectors/emits. No React, no |
| 66 | + renderer. This is the bulk of coverage. |
| 67 | +2. **Minimal React tests second**: only for the adapter seams — hook |
| 68 | + reactivity, provider wiring, arbitration visible through rendering. Use the |
| 69 | + existing `test/support/test-render.ts` harness. |
| 70 | + |
| 71 | +## Selector discipline (TUI render cost) |
| 72 | + |
| 73 | +TUI frames are expensive; store subscriptions must not cause avoidable |
| 74 | +re-renders. |
| 75 | + |
| 76 | +- Components subscribe with `useSelector(store, selector)` and the |
| 77 | + **narrowest selector that answers the question** (`selectHasOverlay`, not |
| 78 | + the whole stack; `selectActiveSurfaceMeta`, not the surfaces array). |
| 79 | +- Selectors returning fresh arrays/objects must either be memoized against |
| 80 | + context identity or passed an equality function; prefer selecting primitives |
| 81 | + and stable references. |
| 82 | +- Transitions must preserve reference identity of untouched slices |
| 83 | + (spread-copy only the slice that changed) so selector equality checks |
| 84 | + short-circuit. |
| 85 | +- Never subscribe a hot render path (per-row components) to a store that |
| 86 | + changes per keystroke; select at the provider/container level and pass |
| 87 | + values down. |
| 88 | + |
| 89 | +## Dependency policy |
| 90 | + |
| 91 | +`@xstate/store` (v4) is the only store dependency, plus `@xstate/store-react` |
| 92 | +(v2) for the React `useSelector` adapter in packages with React seams (v4 |
| 93 | +moved framework adapters out of the core package). Do not add full `xstate` |
| 94 | +(statecharts, actors) without a written justification in the relevant design |
| 95 | +doc — synchronous UI transitions do not earn it. |
| 96 | + |
| 97 | +## One lifecycle: surfaces and overlays |
| 98 | + |
| 99 | +The command surface stack (`@tooee/commands`) and the overlay stack |
| 100 | +(`@tooee/overlays`) are separate stores in separate packages, but they model |
| 101 | +one lifecycle: opening an overlay with `ownCommands` mounts a command surface; |
| 102 | +closing it pops the surface. `@tooee/overlays` must not depend on |
| 103 | +`@tooee/commands`; the shell's `OverlayProvider` is the bridge that ties the |
| 104 | +two stores together (mode restoration, sequence reset on same-id overlay |
| 105 | +replacement). |
0 commit comments