12 KiB
Canvas performance: options and refactoring for scalability
This document lists concrete options to improve canvas (React Flow graph editor) performance, with design patterns and refactoring for scalability. Implement in order of impact vs effort; measure before/after where possible.
1. Stabilize nodes passed to React Flow (high impact, low effort)
Problem: In CanvasPage.tsx, nodes are passed as:
nodes={nodes.map((n) => ({ ...n, className: [n.className, 'nowheel'].filter(Boolean).join(' ') }))}
Every render creates a new array and new object references for every node. React Flow may re-render or reconcile more than needed.
Options:
-
A. Compute derived props in a
useMemo
Memoize the mapped nodes; depend only onnodesand a stableclassNamesuffix so the reference changes only whennodesactually changes. -
B. Avoid mutating node shape in the parent
Apply thenowheelclass vianodeTypesdefault or a wrapper so you can passnodes={nodes}directly. Keeps a single source of truth and avoids creating new node objects every render.
Pattern: Single source of truth — avoid deriving a new structure on every render when the same structure can be expressed at the data or type level.
2. Split FlowContext to reduce consumer re-renders (high impact, medium effort)
Problem: FlowContext holds graph state (nodes, edges, setters), connection path state (trigger/updating/paused/error sets and callbacks), and UI state (renaming, fullscreen, connectionFrom, etc.). Any change to any part creates a new context value, so every node and every edge that uses useContext(FlowContext) re-renders on any graph or path update.
Options:
-
A. Split into multiple contexts (recommended)
- GraphContext:
nodes,edges,setNodes,setEdges,applyGraph, etc. - ConnectionPathContext: path node IDs, trigger/updating/paused/error state and callbacks.
- FlowUIContext:
renamingNodeId,fullscreenNodeId,connectionFrom,flowActionsRef,isValidConnection.
Nodes that only need graph + UI (e.g. for
updateData,setFullscreenNodeId) don’t re-render when only connection path state changes. Edges that only need connection path state don’t re-render when onlynodes/edgeschange (e.g. drag position). - GraphContext:
-
B. Context + selectors
Keep a single store (e.g. Zustand) and have nodes/edges subscribe with selectors (e.g.useStore(selector)). Only re-render when the selected slice changes. This is a larger refactor but scales well.
Pattern: Segregation of concerns — separate contexts (or stores) by update frequency and by consumer type so that high-frequency updates (e.g. connection path ticks) don’t force re-renders of all nodes/edges.
3. Memoize edge component and narrow its context usage (high impact, low effort)
Problem: AnimatedEdge uses useContext(FlowContext) and reads nodes, connectionPathNodeIds, connectionPathPausedSegmentNodeIds, connectionPathActiveSegmentNodeIds, connectionPathErrorNodeIds. So every edge re-renders whenever the whole context value changes (e.g. any node move or path update).
Options:
-
A. Wrap
AnimatedEdgeinReact.memo
Use a custom comparison that returns true whenid,source,target, positions, and style haven’t changed. React Flow already passes stable edge props; memo avoids re-renders when parent re-renders with the same props. -
B. Feed only connection path data into edges
After splitting context (see §2), edges consume only ConnectionPathContext. Then they re-render only when path/trigger/updating/paused/error state changes, not on everynodes/edgeschange. -
C. Pass path state via React Flow edge
data
Compute per-edge “connection status” (or relevant path IDs) in the canvas and pass it asedge.data. Edges become pure in terms of context: they only needdataand standard edge props. Pushing the computation to the parent is one place to update when path state changes.
Pattern: Minimize subscriber set — each component should subscribe only to the minimal state it needs (context split or selectors), and be memoized so parent re-renders don’t force unnecessary work.
4. Reduce history and save cost (medium impact, medium effort)
Problem: In useGraphStateWithHistory, every setNodes / setEdges call runs cloneState(), which deep-clones all nodes and edges. With many nodes or large node.data, this is expensive. In useCanvasGraph, the save effect runs on every nodes/edges change (with a 500 ms debounce), so frequent updates (e.g. drag) still schedule many saves.
Options:
-
A. Structural sharing / copy-on-write
Keep history as immutable updates (e.g. only store changed nodes/edges or patches) instead of full clones. On undo/redo, apply patches or merge with previous state. This reduces both memory and CPU for large graphs. -
B. Throttle or idle-based save
Besides debouncing, only callsaveGraphToStoragewhen the graph hasn’t changed for N seconds or whenrequestIdleCallbackfires. Reduces work during continuous interaction. -
C. Limit history size and clone only when necessary
You already cap history (e.g.MAX_HISTORY). EnsurecloneStateis only called when pushing to history, not on silent updates (e.g. drag). You already usesetNodesSilentfor drag; double-check that no accidental pushes happen during drag.
Pattern: Immutability with minimal copying — use structural sharing or patch-based history so that only changed parts of the graph are copied and stored.
5. Contextual zoom and viewport (medium impact, low–medium effort)
Problem: ContextualZoomNode uses useViewport() from React Flow. Viewport (zoom/pan) updates can trigger re-renders of every contextual node when zoom crosses the compact threshold.
Options:
-
A. Subscribe only near threshold
If the library allows, subscribe to viewport only when zoom is nearCONTEXTUAL_ZOOM_THRESHOLDso small zoom changes don’t re-render all nodes. If not, consider throttling viewport updates before passing to context. -
B. Use a single “display mode” in context
One component (e.g. canvas container) subscribes to viewport and sets a value likedisplayMode: 'compact' | 'full'in context. Nodes only consume that enum; they don’t subscribe to raw zoom. Fewer subscribers and simpler logic. -
C. Keep current behavior but ensure nodes are memoized
WithcreateContextualNode, the inner node is already wrapped (e.g. viacreateAbstractNodeComponent). Ensure the outer wrapper doesn’t break memo (e.g. avoid passing new object/function refs from viewport into the inner node).
Pattern: Facade / single subscriber — one place turns “viewport” into a coarse decision (e.g. compact vs full); the rest of the tree depends only on that decision.
6. React Flow configuration (medium impact, low effort)
Problem: Defaults may render or update more than needed for large graphs.
Options:
-
A. Enable
onlyRenderVisibleElements
When supported and stable in your version, enable it so nodes (and optionally edges) outside the viewport are not rendered. Best combined with fixed node dimensions and explicit handle positions so edges still draw correctly when nodes are off-screen. -
B. Set
nodeOrigin
If you use a consistent origin (e.g. top-left) for all nodes, setnodeOriginso React Flow doesn’t have to infer it; can help with layout and hit-testing. -
C. Reduce MiniMap/Background work
If the minimap or background is expensive, consider making them optional (e.g. only when node count > N) or simplifying their rendering (e.g. simpler background pattern, minimap with lower refresh rate).
Pattern: Use platform features — lean on React Flow’s built-in options (visibility culling, nodeOrigin) before adding custom virtualization.
7. Heavy node components (e.g. RenderingNode) (medium impact, medium effort)
Problem: Nodes that run the resolve → render pipeline (e.g. useRenderingNodeState) do a lot of work: signatures, config resolution, rendering, connection path lifecycle. They already use useAbstractNode and memo via createAbstractNodeComponent; the main cost is internal (hooks, effects, possibly re-renders when context changes).
Options:
-
A. Ensure they only get connection path updates when relevant
After splitting context (§2), these nodes should only subscribe to graph + UI (and maybe a minimal connection path slice if they need to report “updating”/“paused”/“error”). They shouldn’t re-render on every path tick. -
B. Lazy or deferred computation
For heavy useMemos (e.g.buildSourceSignatures), consideruseDeferredValueor moving work off the critical path (e.g. in a worker or requestIdleCallback) if the UI can show a stale state briefly. -
C. Virtualize or hide when not visible
Combined withonlyRenderVisibleElementsor a custom “nodes in viewport” list, heavy nodes that are off-screen don’t mount at all, so their hooks and effects don’t run.
Pattern: Defer and cull — avoid running expensive logic for nodes that are not visible or not relevant to the current interaction.
8. Node types and handlers (lower impact, low effort)
Problem: nodeTypes and edgeTypes are already built with useMemo in CanvasPage. Handlers are mostly useCallback. Remaining issues are usually unstable references or unnecessary dependencies.
Options:
-
A. Keep
nodeTypes/edgeTypesstable
EnsuregetRegisteredNodeTypes()isn’t returning new array/object references every time; if it is, memoize at the registry level or in the hook that buildsnodeTypes. -
B. Ensure
isValidConnectiondoesn’t close over changingnodes
You already usenodesinisValidConnection; that’s correct for validation. If this callback is passed in context and triggers many re-renders, consider moving it to a ref (e.g.isValidConnectionRef.current) so the context value doesn’t change whennodeschanges, and the validator always reads the latest nodes from the ref. Then nodes/edges that only needisValidConnectiondon’t re-render on every graph change. -
C. Memoize
defaultEdgeOptionsandsnapGrid
You already havedefaultEdgeOptions = useMemo(() => ({ type: 'animated' }), [])andSNAP_GRIDconstant. Keep that pattern for any other object/array props passed to<ReactFlow>.
Pattern: Stable references — any prop or context value that is an object or function should be memoized or stored in a ref so that consumers don’t re-render unnecessarily.
9. Connection path hook (lower impact, already partially optimized)
Problem: useCanvasConnectionPath derives connectionPathNodeIds, connectionPathPausedSegmentNodeIds, and connectionPathActiveSegmentNodeIds with useMemo; batching with requestAnimationFrame is already used for trigger updates. The main cost is that when this state changes, every edge (and possibly nodes using path role) re-renders if they all consume the same context.
Options:
-
A. After splitting context (§2), only edges (and path-aware nodes) subscribe to connection path context
Then path updates no longer force re-renders of nodes that don’t care about path. -
B. Keep derived sets in refs for equality checks
If the same set of IDs is produced repeatedly, avoid updating state (or context) when the set content is equal (e.g. compareArray.from(set).sort().join(',')or use a stable serialization) so that consumers don’t see a “new” reference and re-render.
Pattern: Stable outputs — when feeding context or state, avoid new object/set/array references when the logical value hasn’t changed.
Suggested order of implementation
- Quick wins: §1 (stabilize
nodes), §3A (memoizeAnimatedEdge), §6 (React Flow options), §8 (stable refs/callbacks). - High leverage: §2 (split FlowContext), then §3B/3C (edges consume only path state or
data). - Scalability: §4 (history/save), §5 (viewport/contextual zoom), §7 (heavy nodes and visibility).
This order keeps design patterns (context split, minimal subscription, stable references) consistent and sets you up for further scalability (e.g. more nodes, more edges, more complex node content) without large rewrites.