# Canvas state: design pattern for controlled, centralized, debuggable flow This doc proposes a **store + commands + selectors** pattern so canvas state is: - **Controlled** – every change goes through one place - **Centralized** – one store holds graph, path, and UI slices - **Predictable** – same action → same state transition; easy to reason about - **Easier to debug** – log commands, inspect store, optional time-travel It complements [CANVAS_PERFORMANCE_OPTIONS.md](./CANVAS_PERFORMANCE_OPTIONS.md) and [state.ts](../src/lib/graph/state.ts). --- ## 1. Core idea: single store + commands + selectors ### 1.1 Single store (one source of truth) Keep all canvas-related state in **one store** with **slices**: ``` Store ├── graph: { nodes, edges } // current graph (with history if needed) ├── path: ConnectionPathState // trigger/updating/paused/error node IDs ├── ui: FlowUIState // renaming, fullscreen, connectionFrom, etc. └── (optional) history: HistoryState // undo/redo stack ``` - **No duplicate sources**: nodes/edges live only in the store, not in context + refs. - **Reads**: components get data via **selectors** (e.g. `useStore(s => s.graph.nodes)` or `useStore(selectPathForEdge, edgeId)`). - **Writes**: only via **commands** (e.g. `dispatch({ type: 'graph/setNodes', payload: updater })`). ### 1.2 Commands (controlled mutations) Every mutation is a **command** (action): - **Graph**: `graph/setNodes`, `graph/setEdges`, `graph/applySilent` (position), `graph/undo`, `graph/redo` - **Path**: `path/addTrigger`, `path/startUpdate`, `path/endUpdate`, `path/setPaused`, `path/setError` - **UI**: `ui/setRenaming`, `ui/setFullscreen`, `ui/setConnectionFrom` Benefits: - **Predictable**: one command → one reducer → one new state; no scattered `setState` in hooks. - **Traceable**: log every command (and payload) in dev; replay or inspect. - **Testable**: test reducers with command + prev state → next state. - **Time-travel (optional)**: store past states or inverse deltas per command for debug UI. ### 1.3 Selectors (derived state and subscriptions) **Selectors** are pure functions `(state) => value`. They: - **Derive** values (e.g. path node IDs from trigger/updating/paused). - **Scope** data (e.g. “incoming edges for node X”, “connection status for edge Y”). - **Stabilize** references when the logical value hasn’t changed (e.g. same path IDs → same Set reference). Components **subscribe via selectors**: - `useStore(selectGraph)` → re-render when `graph` slice changes. - `useStore(selectPathNodeIds)` → re-render only when path node IDs change. - `useStore(selectConnectionStatusForEdge, edgeId)` → re-render only when that edge’s status changes. So: - **Centralized**: all reads go through the store. - **Predictable**: same state in → same selector out. - **Performance**: only components whose selected value changed re-render (with a store that supports shallow equality, e.g. Zustand). --- ## 2. Data structures ### 2.1 Store shape (TypeScript) ```ts // Slices match current concepts; easy to migrate from existing state.ts + useCanvasConnectionPath. interface CanvasStore { graph: { nodes: AppNode[] edges: AppEdge[] } path: ConnectionPathState // from state.ts ui: FlowUIState // optional, for undo/redo _history?: { past: HistoryDelta[] future: HistoryDelta[] } } ``` ### 2.2 Commands (discriminated union) ```ts type CanvasCommand = | { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) } | { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) } | { type: 'graph/applySilent'; payload: (prev: GraphState) => GraphState } | { type: 'path/addTrigger'; payload: string } | { type: 'path/startUpdate'; payload: string } | { type: 'path/endUpdate'; payload: string } | { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } } | { type: 'path/setError'; payload: { nodeId: string; error: boolean } } | { type: 'ui/setRenaming'; payload: string | null } | { type: 'ui/setFullscreen'; payload: string | null } // ... ``` Single dispatcher: ```ts function dispatch(cmd: CanvasCommand): void ``` ### 2.3 Selectors (examples) ```ts // Raw slices const selectGraph = (s: CanvasStore) => s.graph const selectPath = (s: CanvasStore) => s.path // Stable derived path sets (same ref if same IDs) const selectPathNodeIds = (s: CanvasStore) => getPathNodeIds(s.graph.edges, s.path...) // Per-edge status (for AnimatedEdge) – only changes when this edge’s status changes const selectConnectionStatusForEdge = (s: CanvasStore, source: string, target: string) => getConnectionStatus({ source, target, pathNodeIds: s.path.connectionPathNodeIds, ... }) // Per-node: “am I on path?” (for BaseNode) const selectPathRoleForNode = (s: CanvasStore, nodeId: string) => getConnectionPathRole(nodeId, s.path) ``` Use with a store that supports **selector + equality** so components only re-render when the selected value actually changes (e.g. Zustand’s `useStore(selector, shallowEqual)` or custom `useSelector`). --- ## 3. Why this helps | Goal | How the pattern helps | |------|------------------------| | **Controlled** | All writes go through `dispatch(cmd)`. No ad-hoc `setState` in hooks or context. | | **Centralized** | One store; no split between context, refs, and local state for the same concept. | | **Predictable** | One command → one reducer → one new state. Order of updates is explicit. | | **Easier to debug** | Log commands; inspect store (e.g. Redux DevTools or a simple `store.getState()` logger); optional time-travel by replaying or reverting commands. | | **Fewer redraws** | Selectors + equality checks mean components only re-render when their slice or derived value changes. | | **Clear data flow** | Data flow is “store → selectors → components” and “events → commands → store”; no implicit propagation. | --- ## 4. Implementation options ### Option A: Zustand (recommended for React) - **Store**: `create()` with a `dispatch` that applies commands and updates the store. - **Selectors**: `useCanvasStore(selectPathNodeIds)` etc.; Zustand re-renders only when the selected value changes (with shallow or custom equality). - **Commands**: either one `setState` that takes a reducer, or a separate `dispatch` that maps commands to `setState` calls. - **Debug**: middleware that logs commands and state (or use Redux DevTools with a small adapter). ### Option B: Redux Toolkit - **Store**: one RTK store; slices: `graph`, `path`, `ui`. - **Commands**: RTK actions; reducers are pure and easy to test. - **Selectors**: `createSelector` for derived state; `useSelector` for subscriptions. - **Debug**: Redux DevTools out of the box (time-travel, action log, state diff). ### Option C: Minimal custom store (no new deps) - **Store**: a single `useReducer` (or `useState` + reducer) at the top (e.g. CanvasPage or a provider). - **Commands**: dispatch to the reducer; reducer returns new state by slice. - **Selectors**: pass store (or state) to a `useSelector(store, selector, equality)` hook that subscribes and only re-renders when the selected value changes (e.g. by comparing with `Object.is` or shallow compare). - **Debug**: log `dispatch` and state in dev; optional snapshot history in the reducer. --- ## 5. Migration path from current setup 1. **Introduce the store** (e.g. Zustand or RTK) next to existing context; keep feeding React Flow and current consumers from the store so behavior stays the same. 2. **Move graph state** from `useGraphStateWithHistory` into the store (graph slice + history if needed); keep `setNodes`/`setEdges` as commands that update the store. 3. **Move path state** from `useCanvasConnectionPath` into the store (path slice); replace path context with `useStore(selectPath...)` or per-edge/per-node selectors. 4. **Move UI state** from CanvasPage `useState` into the store (ui slice); replace FlowUIContext with store selectors. 5. **Remove redundant context** (GraphContext, ConnectionPathContext, FlowUIContext) once all reads go through selectors and all writes through commands. 6. **Add logging / DevTools** for commands and state; add optional time-travel if desired. This can be done slice-by-slice (e.g. path first, then graph, then UI) to keep changes small and testable. --- ## 6. Implementation (Zustand) The store is implemented under `frontend/src/app/canvas/`: | File | Purpose | |------|---------| | `canvasStore.types.ts` | `CanvasStore`, `CanvasCommand`, slice types | | `canvasStore.reducer.ts` | Pure reducer + `initialCanvasStore` | | `canvasStore.selectors.ts` | Selectors (graph, path derived sets, per-edge status, per-node role) | | `canvasStore.ts` | Zustand store, `getCanvasStore()`, `dispatchCanvasCommand()`, `useCanvasStore()`, `useCanvasStoreDispatch()`; dev logging of commands | | `canvasStore.index.ts` | Re-exports for consumers | | `canvasStore.test.ts` | Test suite (reducer, selectors, store integration) | **Run tests:** `npm run test:run` (or `npm run test` for watch) in `frontend/`. **Usage:** Import from `@/app/canvas/canvasStore` or `@/app/canvas/canvasStore.index`: - `dispatchCanvasCommand({ type: 'graph/setNodes', payload: nodes })` - `useCanvasStore(selectPathNodeIds)` or `useCanvasStore(selectConnectionStatusForEdge, ...)` (selectors take state; for per-edge/per-node use a factory selector in the component) - `useCanvasStoreDispatch()` for stable dispatch in components Migration from existing context: feed the store from CanvasPage (or sync store ↔ existing hooks) and gradually replace context consumers with `useCanvasStore(selector)` and `dispatchCanvasCommand`. See §5 migration path. --- ## 7. Summary - **Pattern**: one **store** (graph + path + ui), **commands** for all mutations, **selectors** for reads and derived state. - **Data structures**: flat slices in the store; commands as a discriminated union; selectors as pure functions (state [, args]) → value. - **Benefits**: controlled, centralized, predictable, easier to debug, and fewer unnecessary redraws via selector-based subscriptions. - **Concrete next step**: migrate one consumer (e.g. AnimatedEdge) to `useCanvasStore(selectConnectionStatusForEdge)` with a per-edge selector and `dispatchCanvasCommand` for path updates; then remove its ConnectionPathContext dependency.