Files
zui/frontend/docs/CANVAS_STATE_DESIGN.md
2026-03-13 00:32:19 +01:00

10 KiB
Raw Permalink Blame History

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) 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 hasnt 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 edges 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 edges 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. Zustands 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

  • Store: create<CanvasStore>() 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.