10 KiB
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 and 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)oruseStore(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
setStatein 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 whengraphslice 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)
// 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)
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:
function dispatch(cmd: CanvasCommand): void
2.3 Selectors (examples)
// 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<CanvasStore>()with adispatchthat 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
setStatethat takes a reducer, or a separatedispatchthat maps commands tosetStatecalls. - 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:
createSelectorfor derived state;useSelectorfor 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(oruseState+ 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 withObject.isor shallow compare). - Debug: log
dispatchand state in dev; optional snapshot history in the reducer.
5. Migration path from current setup
- 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.
- Move graph state from
useGraphStateWithHistoryinto the store (graph slice + history if needed); keepsetNodes/setEdgesas commands that update the store. - Move path state from
useCanvasConnectionPathinto the store (path slice); replace path context withuseStore(selectPath...)or per-edge/per-node selectors. - Move UI state from CanvasPage
useStateinto the store (ui slice); replace FlowUIContext with store selectors. - Remove redundant context (GraphContext, ConnectionPathContext, FlowUIContext) once all reads go through selectors and all writes through commands.
- 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)oruseCanvasStore(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 anddispatchCanvasCommandfor path updates; then remove its ConnectionPathContext dependency.