| name | visualiser-performance |
| description | React Flow performance rules and review checklist for the @eventcatalog/visualiser package. Automatically applies when making changes to any file under packages/visualiser/. Use this skill to audit, review, or implement visualiser code with performance in mind. |
| globs | ["packages/visualiser/**/*.tsx","packages/visualiser/**/*.ts"] |
Visualiser Performance Rules
When modifying any code in packages/visualiser/, follow these rules to avoid React Flow performance regressions. A single unoptimized line can cause all nodes to re-render on every drag tick, dropping FPS from 60 to 2.
Rule 1: Never pass unstable references to <ReactFlow> props
All props on <ReactFlow> must be referentially stable:
- Objects/arrays: Define outside the component or wrap in
useMemo with stable deps
- Functions: Wrap in
useCallback with stable deps
- NEVER pass anonymous functions (
onClick={() => {}}) or inline objects directly
<ReactFlow onNodeClick={() => {}} />
const handleNodeClick = useCallback(() => {}, []);
<ReactFlow onNodeClick={handleNodeClick} />
nodeTypes and edgeTypes must be memoized with useMemo(() => ..., []) or defined outside the component. These are currently correct in NodeGraph.tsx.
Rule 2: Never depend on nodes/edges arrays for structural data
The nodes and edges arrays from useNodesState/useEdgesState get new references on every position change (drag). If you put them in a useMemo/useEffect dependency array, that code runs on every drag tick.
Pattern: Use stable structural keys
When you only care about which nodes exist (not their positions), derive a stable key using useRef:
const nodeIdsKeyRef = useRef("");
const computedKey = nodes.map((n) => n.id).join(",");
if (computedKey !== nodeIdsKeyRef.current) {
nodeIdsKeyRef.current = computedKey;
}
const nodeIdsKey = nodeIdsKeyRef.current;
const searchNodes = useMemo(() => nodes, [nodeIdsKey]);
For edges, include source/target in the key:
const edgeKey = edges.map((e) => `${e.source}-${e.target}`).join(",");
Never do this:
useEffect(() => { }, [nodes, edges]);
const selected = useMemo(() => nodes.filter(n => n.selected), [nodes]);
Rule 3: Always wrap custom nodes and edges in memo()
Every custom node and edge component MUST be wrapped in React.memo. This is the single most impactful optimization — it prevents node content from re-rendering during drag even if parent state changes.
export default memo(function MyNode(props: NodeProps) {
return <div>...</div>;
});
All current node components (ServiceNode, EventNode, CommandNode, QueryNode, ChannelNode, DataNode, ViewNode, ActorNode, NoteNode, ExternalSystem, Custom, Entity, Step, Domain, Flow, DataProduct, User) are correctly wrapped.
All edge components (AnimatedMessageEdge, MultilineEdgeLabel, FlowEdge) are correctly wrapped.
Do not break this pattern when adding new node or edge types.
Rule 4: Memoize heavy sub-components inside nodes
If a node renders complex sub-components (data grids, forms, SVG animations), wrap those in memo() too. This prevents the inner content from re-rendering even when the node itself re-renders.
const GlowHandle = memo(function GlowHandle({ side }: { side: "left" | "right" }) {
return <div style={{...}} />;
});
Currently memoized sub-components: GlowHandle (in ServiceNode, EventNode, CommandNode, QueryNode), MiniEnvelope, ServiceMessageFlow (in ServiceNode).
Rule 5: Avoid useStore selectors that return new references
If using ReactFlow's useStore (or any Zustand store), never return arrays/objects that get recreated on every state change:
const selected = useStore(state => state.nodes.filter(n => n.selected));
const selectedIds = useStore(
state => state.nodes.filter(n => n.selected).map(n => n.id)
);
import { useShallow } from 'zustand/react/shallow';
const [a, b] = useStore(useShallow(state => [state.a, state.b]));
Checklist for PR review
When reviewing visualiser changes, verify:
Key files
| File | What to check |
|---|
src/components/NodeGraph.tsx | ReactFlow props, structural keys, legend computation |
src/components/StepWalkthrough.tsx | Effect dependencies use stable keys |
src/components/VisualiserSearch.tsx | Search filtering uses stable node snapshot |
src/components/FocusMode/FocusModeContent.tsx | Focus graph calculation deps |
src/nodes/*/ | All node components wrapped in memo() |
src/edges/*/ | All edge components wrapped in memo() |
Reference
Based on: "The Ultimate Guide to Optimize React Flow Project Performance" by Lukasz Jazwa. Key benchmarks from that article (100 nodes):
- Anonymous function on ReactFlow prop: 60 FPS -> 10 FPS (default), 2 FPS (heavy)
- Node depending on full nodes array via useStore: 60 FPS -> 12 FPS
- Adding React.memo to nodes: recovers to 50-60 FPS even with non-optimal parent
- Memoizing heavy node content: recovers to 60 FPS stable