
React Flow
- 580 installs
- 41 repo stars
- Updated July 16, 2026
- framara/react-flow-skill
react-flow is an agent skill that guides React Flow node-graph UI implementation for developers who need interactive diagrams, editors, or workflow canvases in React applications.
About
Provides an agent behavior contract and patterns for building canvas-based node graphs with React Flow v12+, covering custom nodes, handles, Zustand state, and performance. A React developer uses it when building or debugging node-editor UIs.
- Rules like explicit container size and nodeTypes defined outside components
- Covers custom nodes, handles, layouting, and Zustand integration
React Flow by the numbers
- 580 all-time installs (skills.sh)
- Ranked #582 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/framara/react-flow-skill --skill react-flowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 580 |
|---|---|
| repo stars | ★ 41 |
| Last updated | July 16, 2026 |
| Repository | framara/react-flow-skill ↗ |
How do you build node graph UIs in React?
Guides building interactive node-based graphs with React Flow (@xyflow/react), including custom nodes, edges, handles, layouting, and performance.
Who is it for?
Frontend developers adding workflow editors, pipeline diagrams, or agent graph visualizations with React Flow.
Skip if: Projects needing static charts only or backends without a React node-canvas requirement.
When should I use this skill?
A developer asks to add, debug, or extend a React Flow diagram, node editor, or edge-routing canvas.
What you get
Working React Flow canvas with connected nodes, edges, and layout behavior.
- React Flow canvas components
- Node and edge configuration
Files
React Flow
Overview
Use this skill to build, customize, debug, and optimize interactive node-based UIs with React Flow (@xyflow/react v12+). Covers everything from basic setup to advanced patterns like computed flows, sub-flows, and external layout integration.
Agent behavior contract (follow these rules)
1. Always import from @xyflow/react — never from legacy reactflow or react-flow-renderer packages. 2. Always import the stylesheet: import '@xyflow/react/dist/style.css' (or base.css for custom styling frameworks). 3. The <ReactFlow> parent container must have explicit width and height — this is the #1 cause of blank canvases. 4. Define nodeTypes and edgeTypes objects outside component bodies or wrap in useMemo to prevent re-renders. 5. Prefer custom nodes over built-in nodes — the React Flow team explicitly recommends this. 6. Use the nodrag class on interactive elements inside custom nodes (inputs, buttons, selects). 7. Use nowheel class on scrollable elements inside custom nodes to prevent zoom interference. 8. When hiding handles, use visibility: hidden or opacity: 0 — never display: none (breaks dimension calculation). 9. When using multiple handles of the same type on a node, always assign unique id props. 10. After programmatically adding/removing handles, call useUpdateNodeInternals to refresh the node. 11. Always create new objects when updating node/edge state — mutations are not detected by React Flow. 12. Prefer controlled flows (with onNodesChange/onEdgesChange/onConnect) for any non-trivial application.
First 60 seconds (triage template)
- Clarify the goal: new flow setup, custom nodes/edges, state management, layout, performance, styling, E2E testing, advanced patterns (undo/redo, copy/paste, computed flows, collaboration), or debugging.
- Collect minimal facts:
- React Flow version (v12+ uses
@xyflow/react) - TypeScript or JavaScript
- State management approach (local state, Zustand, Redux)
- Number of nodes expected (affects performance strategy)
- Styling approach (CSS, Tailwind, styled-components)
- Branch quickly:
- migrating from legacy
reactfloworreact-flow-renderer-> package rename, import changes, API differences - blank canvas or missing nodes -> container dimensions or missing CSS import
- edges not rendering -> missing handles, missing CSS, or
display: noneon handles - re-renders or sluggish performance -> nodeTypes defined inside component, missing memoization
- connecting nodes not working -> missing
onConnecthandler or handle configuration - layout/positioning -> external layout library integration (dagre, elkjs)
- type errors -> TypeScript generic patterns for Node/Edge types
Routing map (read the right reference fast)
- Migrating from
reactfloworreact-flow-rendererto@xyflow/reactv12 ->references/migration.md - Installation, setup, first flow, node/edge objects ->
references/fundamentals.md - Custom node components, Handle, multiple handles, drag handles ->
references/custom-nodes.md - Custom edge components, path utilities, edge labels, markers ->
references/custom-edges.md - Event handlers, callbacks, connection validation, selection, keyboard ->
references/interactivity.md - Controlled vs uncontrolled, Zustand integration, state update patterns ->
references/state-management.md - Node/Edge types, generics, union types, type guards ->
references/typescript.md - External layout libraries (dagre, elkjs, d3), sub-flows, parent-child ->
references/layouting.md - Background, Controls, MiniMap, Panel, NodeToolbar, NodeResizer, hooks ->
references/components-and-hooks.md - Memoization, render optimization, theming, CSS variables, Tailwind ->
references/performance-and-styling.md - Common errors, debugging, edge display issues, Zustand warnings ->
references/troubleshooting.md - Playwright E2E tests, flow selectors, drag/viewport/connection testing ->
references/e2e-testing.md - Undo/redo, copy/paste, computed flows, dynamic handles, save/restore, collaboration ->
references/advanced-patterns.md - Context menu add node, drag-and-drop sidebar, detail panel, export as image ->
references/recipes.md
Common pitfalls -> next best move
- Blank canvas with no errors -> parent container has no height; set explicit
height: 100vhor equivalent. nodeTypes/edgeTypeswarning -> move object definition outside component body or wrap inuseMemo.- Edges render but in wrong position -> handles use
display: none; switch toopacity: 0. - Cannot interact with inputs inside nodes -> add
className="nodrag"to interactive elements. - Nodes snap back after drag ->
onNodesChangenot wired up or not applying changes correctly. - Connection line appears but edge never creates ->
onConnecthandler missing or not callingaddEdge. - Multiple handles on same side overlap -> position them with CSS (
topoffset) and assign uniqueids. - State updates don't reflect in nodes -> creating mutations instead of new objects; spread operator required.
- Zustand context warning -> two versions of
@xyflow/reactinstalled or missing<ReactFlowProvider>. - Sub-flow child nodes render behind parent -> ensure parent nodes appear before children in the
nodesarray.
Verification checklist
- Confirm
@xyflow/react/dist/style.cssis imported (orbase.css+ custom styles). - Confirm parent container has explicit width and height.
- Confirm
nodeTypes/edgeTypesare stable references (defined outside component or memoized). - Confirm custom nodes use
<Handle>components with propertypeandposition. - Confirm interactive elements inside nodes have
nodragclass. - Confirm controlled flows wire up all three handlers:
onNodesChange,onEdgesChange,onConnect. - Confirm state updates create new node/edge objects (no mutations).
- Confirm TypeScript generics are applied to hooks and callbacks for type safety.
- Confirm performance-sensitive flows memoize custom node/edge components with
React.memo.
References
references/migration.mdreferences/fundamentals.mdreferences/custom-nodes.mdreferences/custom-edges.mdreferences/interactivity.mdreferences/state-management.mdreferences/typescript.mdreferences/layouting.mdreferences/components-and-hooks.mdreferences/performance-and-styling.mdreferences/troubleshooting.mdreferences/e2e-testing.mdreferences/advanced-patterns.mdreferences/recipes.md
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What This Repo Is
This is a Claude Code skill (not an application). It provides expert React Flow (@xyflow/react v12+) guidance that Claude Code uses automatically when helping users build node-based UIs. The skill is installed via npx skills add framara/react-flow-skill.
Repository Structure
SKILL.md— The skill definition file. Contains the agent behavior contract (12 rules), triage template, routing map, common pitfalls, and verification checklist. This is the entry point Claude Code reads when the skill activates.references/— 14 topic-specific reference files that SKILL.md routes to based on user needs (migration, fundamentals, custom nodes/edges, state management, layouting, TypeScript, performance, troubleshooting, E2E testing, advanced patterns, common recipes, etc.)- No build system, tests, or application code — this repo is purely markdown-based reference content.
Editing Guidelines
- SKILL.md is the contract: Any behavioral rules, pitfall patterns, or verification steps belong here. Keep it concise — it's loaded into context on every activation.
- References are the depth: Detailed code examples, API patterns, and implementation guides go in
references/*.md. SKILL.md's routing map must stay in sync with reference file contents. - When adding a new reference topic: create
references/<topic>.md, add a routing entry in SKILL.md's "Routing map" section, and list it in SKILL.md's "References" section and README.md's feature list.
MIT License
Copyright (c) 2026 framara
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
react-flow-skill
A Claude Code skill for building interactive node-based UIs with React Flow (@xyflow/react v12+).
What's included
The skill provides expert guidance across 14 reference topics:
- Migration - Upgrading from
reactflow/react-flow-rendererto@xyflow/reactv12 - Fundamentals - Installation, setup, first flow, node/edge objects
- Custom Nodes - Custom node components, Handle, multiple handles, drag handles
- Custom Edges - Custom edge components, path utilities, edge labels, markers
- Interactivity - Event handlers, callbacks, connection validation, selection, keyboard
- State Management - Controlled vs uncontrolled, Zustand integration, state update patterns
- TypeScript - Node/Edge types, generics, union types, type guards
- Layouting - External layout libraries (dagre, elkjs, d3), sub-flows, parent-child
- Components & Hooks - Background, Controls, MiniMap, Panel, NodeToolbar, NodeResizer, hooks
- Performance & Styling - Memoization, render optimization, theming, CSS variables, Tailwind
- Troubleshooting - Common errors, debugging, edge display issues, Zustand warnings
- E2E Testing - Playwright setup, React Flow selectors, node/edge/viewport/connection test patterns
- Advanced Patterns - Undo/redo, copy/paste, computed flows, dynamic handles, save/restore, collaboration
- Common Recipes - Context menu node creation, drag-and-drop sidebar, detail panels, export as image
It also includes a 12-rule agent behavior contract covering the most critical React Flow patterns (imports, container sizing, nodeTypes stability, handle visibility, state immutability, and more) so Claude follows best practices automatically.
Installation
npx skills add framara/react-flow-skillTo install globally (all projects):
npx skills add framara/react-flow-skill -gUsage
Once installed, Claude Code will automatically use this skill when you work on React Flow code. Ask it to:
- Set up a new React Flow project
- Create custom nodes and edges
- Debug blank canvas or missing edge issues
- Integrate with Zustand for state management
- Add automatic layouting with dagre or elkjs
- Optimize performance for large graphs
- Write Playwright E2E tests for React Flow applications
- Migrate from legacy
reactflowpackage to@xyflow/reactv12 - Implement undo/redo, copy/paste, or computed data flows
License
MIT
Advanced Patterns
When to use this reference
Use this file when implementing undo/redo, copy/paste, computed data flows, dynamic handles, save/restore, collaborative editing, or other advanced patterns that go beyond basic React Flow setup.
Contents
- Undo / redo
- Copy / paste
- Save and restore
- Computed flows (reactive data pipelines)
- Dynamic handle generation
- Connection validation and cycle prevention
- Connection limits
- Contextual zoom (level-of-detail rendering)
- Collaborative editing
- Do / Don't
Undo / redo
Use a snapshot-based approach: capture nodes and edges state on each meaningful change, push snapshots to a history stack, and navigate back/forward through the stack.
With Zustand + Zundo (recommended)
Zundo is a temporal middleware for Zustand that adds undo/redo automatically. Since React Flow already uses Zustand internally, this is the most natural fit.
npm install zustand zundo immerimport { create } from 'zustand';
import { temporal } from 'zundo';
import { immer } from 'zustand/middleware/immer';
import {
applyNodeChanges,
applyEdgeChanges,
addEdge,
type Node,
type Edge,
type OnNodesChange,
type OnEdgesChange,
type OnConnect,
} from '@xyflow/react';
type FlowState = {
nodes: Node[];
edges: Edge[];
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
onConnect: OnConnect;
setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void;
};
const useFlowStore = create<FlowState>()(
temporal(
immer((set, get) => ({
nodes: [] as Node[],
edges: [] as Edge[],
onNodesChange: (changes) => {
set({ nodes: applyNodeChanges(changes, get().nodes) });
},
onEdgesChange: (changes) => {
set({ edges: applyEdgeChanges(changes, get().edges) });
},
onConnect: (connection) => {
set({ edges: addEdge(connection, get().edges) });
},
setNodes: (nodes) => set({ nodes }),
setEdges: (edges) => set({ edges }),
})),
{
// Only track nodes and edges in history, not handler functions
partialize: (state) => ({
nodes: state.nodes,
edges: state.edges,
}),
},
),
);
export default useFlowStore;Wire up the keyboard shortcuts and undo/redo actions:
import { useCallback, useEffect } from 'react';
import { ReactFlow } from '@xyflow/react';
import { useTemporalStore } from 'zundo';
import useFlowStore from './store';
function Flow() {
const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useFlowStore();
const { undo, redo } = useTemporalStore((state) => state);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [undo, redo]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
/>
);
}Without Zundo (manual implementation)
If you prefer no extra dependency, manage history stacks directly:
import { useCallback, useRef } from 'react';
import { type Node, type Edge } from '@xyflow/react';
type Snapshot = { nodes: Node[]; edges: Edge[] };
export function useUndoRedo(maxHistory = 100) {
const past = useRef<Snapshot[]>([]);
const future = useRef<Snapshot[]>([]);
const takeSnapshot = useCallback((nodes: Node[], edges: Edge[]) => {
past.current = past.current.slice(-maxHistory);
past.current.push({
nodes: structuredClone(nodes),
edges: structuredClone(edges),
});
// Any new action clears the redo stack
future.current = [];
}, [maxHistory]);
const undo = useCallback(
(
currentNodes: Node[],
currentEdges: Edge[],
setNodes: (nodes: Node[]) => void,
setEdges: (edges: Edge[]) => void,
) => {
const previous = past.current.pop();
if (!previous) return;
future.current.push({
nodes: structuredClone(currentNodes),
edges: structuredClone(currentEdges),
});
setNodes(previous.nodes);
setEdges(previous.edges);
},
[],
);
const redo = useCallback(
(
currentNodes: Node[],
currentEdges: Edge[],
setNodes: (nodes: Node[]) => void,
setEdges: (edges: Edge[]) => void,
) => {
const next = future.current.pop();
if (!next) return;
past.current.push({
nodes: structuredClone(currentNodes),
edges: structuredClone(currentEdges),
});
setNodes(next.nodes);
setEdges(next.edges);
},
[],
);
const canUndo = useCallback(() => past.current.length > 0, []);
const canRedo = useCallback(() => future.current.length > 0, []);
return { takeSnapshot, undo, redo, canUndo, canRedo };
}When to call `takeSnapshot`: Before node drag starts (onNodeDragStart), before deletion (onBeforeDelete), before connecting (onConnect), and before any programmatic state change. Do not snapshot on every intermediate drag position — that floods the history.
Copy / paste
Pattern: clipboard events with custom MIME type
Use the browser Clipboard API with a custom data type to avoid interfering with normal text copy/paste. Regenerate IDs on paste and offset positions so pasted nodes don't overlap originals. Remap edge source/target to the new IDs.
import { useCallback, useRef } from 'react';
import { useReactFlow, type Node, type Edge } from '@xyflow/react';
let idCounter = 0;
const newId = () => `pasted_${Date.now()}_${idCounter++}`;
export function useCopyPaste() {
const { getNodes, getEdges, setNodes, setEdges, screenToFlowPosition } =
useReactFlow();
const clipboard = useRef<{ nodes: Node[]; edges: Edge[] } | null>(null);
const copy = useCallback(() => {
const selectedNodes = getNodes().filter((n) => n.selected);
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
// Only copy edges where both source and target are selected
const selectedEdges = getEdges().filter(
(e) => selectedNodeIds.has(e.source) && selectedNodeIds.has(e.target),
);
clipboard.current = {
nodes: structuredClone(selectedNodes),
edges: structuredClone(selectedEdges),
};
}, [getNodes, getEdges]);
const cut = useCallback(() => {
copy();
const selected = getNodes().filter((n) => n.selected);
const selectedIds = new Set(selected.map((n) => n.id));
setNodes((nodes) => nodes.filter((n) => !selectedIds.has(n.id)));
setEdges((edges) =>
edges.filter(
(e) => !selectedIds.has(e.source) && !selectedIds.has(e.target),
),
);
}, [copy, getNodes, setNodes, setEdges]);
const paste = useCallback(
(position?: { x: number; y: number }) => {
if (!clipboard.current) return;
const { nodes: copiedNodes, edges: copiedEdges } = clipboard.current;
// Map old IDs to new IDs
const idMap = new Map<string, string>();
copiedNodes.forEach((n) => idMap.set(n.id, newId()));
// Calculate offset: place relative to original centroid, shifted
const offset = position
? (() => {
const avgX =
copiedNodes.reduce((sum, n) => sum + n.position.x, 0) /
copiedNodes.length;
const avgY =
copiedNodes.reduce((sum, n) => sum + n.position.y, 0) /
copiedNodes.length;
return { x: position.x - avgX, y: position.y - avgY };
})()
: { x: 50, y: 50 };
const newNodes = copiedNodes.map((n) => ({
...n,
id: idMap.get(n.id)!,
position: { x: n.position.x + offset.x, y: n.position.y + offset.y },
selected: true,
dragging: false,
...(n.parentId && idMap.has(n.parentId)
? { parentId: idMap.get(n.parentId)! }
: {}),
}));
const newEdges = copiedEdges.map((e) => ({
...e,
id: newId(),
source: idMap.get(e.source)!,
target: idMap.get(e.target)!,
}));
// Deselect all, then add pasted elements as selected
setNodes((nodes) =>
[...nodes.map((n) => ({ ...n, selected: false })), ...newNodes],
);
setEdges((edges) =>
[...edges.map((e) => ({ ...e, selected: false })), ...newEdges],
);
},
[setNodes, setEdges],
);
return { copy, cut, paste };
}Wire up keyboard shortcuts:
const { copy, cut, paste } = useCopyPaste();
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
// Skip if user is typing in an input
if ((e.target as HTMLElement).closest('input, textarea, select')) return;
if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
copy();
} else if ((e.metaKey || e.ctrlKey) && e.key === 'x') {
cut();
} else if ((e.metaKey || e.ctrlKey) && e.key === 'v') {
paste();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [copy, cut, paste]);Save and restore
Use toObject() from useReactFlow() to serialize the entire flow (nodes, edges, viewport) and restore it later:
import { useCallback } from 'react';
import { useReactFlow } from '@xyflow/react';
function useSaveRestore(storageKey = 'react-flow-state') {
const { toObject, setNodes, setEdges, setViewport } = useReactFlow();
const save = useCallback(() => {
const flow = toObject();
localStorage.setItem(storageKey, JSON.stringify(flow));
}, [toObject, storageKey]);
const restore = useCallback(() => {
const json = localStorage.getItem(storageKey);
if (!json) return;
const flow = JSON.parse(json);
setNodes(flow.nodes || []);
setEdges(flow.edges || []);
const { x = 0, y = 0, zoom = 1 } = flow.viewport || {};
setViewport({ x, y, zoom });
}, [setNodes, setEdges, setViewport, storageKey]);
return { save, restore };
}toObject() returns a ReactFlowJsonObject:
interface ReactFlowJsonObject<NodeType, EdgeType> {
nodes: NodeType[];
edges: EdgeType[];
viewport: { x: number; y: number; zoom: number };
}This is JSON-serializable and works with localStorage, databases, or file exports.
Computed flows (reactive data pipelines)
Build nodes that react to data from connected nodes. Three hooks work together:
| Hook | Purpose |
|---|---|
useNodeConnections({ handleType }) | Discover which nodes are connected to a handle |
useNodesData(nodeIds) | Subscribe to data changes on connected nodes |
updateNodeData(id, data) | Write computed results back to the node |
Input node (writes data)
import { memo } from 'react';
import { Handle, Position, useReactFlow, type NodeProps, type Node } from '@xyflow/react';
type TextNodeData = { text: string };
function TextNode({ id, data }: NodeProps<Node<TextNodeData>>) {
const { updateNodeData } = useReactFlow();
return (
<div className="nodrag">
<Handle type="source" position={Position.Right} />
<input
value={data.text}
onChange={(e) => updateNodeData(id, { text: e.target.value })}
/>
</div>
);
}
export default memo(TextNode);Transform node (reads input, writes output)
import { memo, useEffect } from 'react';
import {
Handle,
Position,
useReactFlow,
useNodeConnections,
useNodesData,
type NodeProps,
} from '@xyflow/react';
function UppercaseNode({ id }: NodeProps) {
const { updateNodeData } = useReactFlow();
const connections = useNodeConnections({ handleType: 'target' });
const sourceData = useNodesData(connections.map((c) => c.source));
useEffect(() => {
const inputText = sourceData[0]?.data?.text ?? '';
updateNodeData(id, { text: inputText.toUpperCase() });
}, [sourceData, id, updateNodeData]);
return (
<div>
<Handle type="target" position={Position.Left} />
<div>uppercase transform</div>
<Handle type="source" position={Position.Right} />
</div>
);
}
export default memo(UppercaseNode);Aggregator node (reads from multiple sources)
import { memo } from 'react';
import { Handle, Position, useNodeConnections, useNodesData } from '@xyflow/react';
function ResultNode() {
const connections = useNodeConnections({ handleType: 'target' });
const nodesData = useNodesData(connections.map((c) => c.source));
return (
<div>
<Handle type="target" position={Position.Left} />
<div>
{nodesData.map(({ id, data }) => (
<div key={id}>{data?.text ?? ''}</div>
))}
</div>
</div>
);
}
export default memo(ResultNode);Conditional branching with multiple output handles
A node can route data to different handles based on computation:
function BranchNode({ id }: NodeProps) {
const { updateNodeData } = useReactFlow();
const connections = useNodeConnections({ handleType: 'target' });
const sourceData = useNodesData(connections.map((c) => c.source));
useEffect(() => {
const value = sourceData[0]?.data?.value ?? 0;
updateNodeData(id, {
high: value > 50 ? value : null,
low: value <= 50 ? value : null,
});
}, [sourceData, id, updateNodeData]);
return (
<div>
<Handle type="target" position={Position.Left} />
<div>if > 50</div>
<Handle type="source" position={Position.Top} id="high" />
<Handle type="source" position={Position.Bottom} id="low" />
</div>
);
}Downstream nodes connect to the specific handle and check for null to know whether they received data.
Dynamic handle generation
When handles are added, removed, or repositioned programmatically, React Flow must recalculate internal dimensions. Call useUpdateNodeInternals() after the change.
import { useCallback, useState } from 'react';
import { Handle, Position, useUpdateNodeInternals, type NodeProps } from '@xyflow/react';
function DynamicHandleNode({ id }: NodeProps) {
const updateNodeInternals = useUpdateNodeInternals();
const [outputs, setOutputs] = useState(['out-1']);
const addHandle = useCallback(() => {
setOutputs((prev) => {
const next = [...prev, `out-${prev.length + 1}`];
// Must call after state update triggers a render
requestAnimationFrame(() => updateNodeInternals(id));
return next;
});
}, [id, updateNodeInternals]);
return (
<div>
<Handle type="target" position={Position.Left} />
<button className="nodrag" onClick={addHandle}>+ output</button>
{outputs.map((handleId, i) => (
<Handle
key={handleId}
type="source"
position={Position.Right}
id={handleId}
style={{ top: `${((i + 1) / (outputs.length + 1)) * 100}%` }}
/>
))}
</div>
);
}Critical: Call updateNodeInternals after the render that adds/removes the handle, not before. Using requestAnimationFrame or placing the call in a useEffect ensures the DOM has updated.
Data-driven handles
Generate handles from node data rather than hardcoding them:
function SchemaNode({ id, data }: NodeProps<Node<{ fields: string[] }>>) {
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
updateNodeInternals(id);
}, [data.fields, id, updateNodeInternals]);
return (
<div>
<Handle type="target" position={Position.Left} />
{data.fields.map((field) => (
<div key={field} style={{ display: 'flex', alignItems: 'center' }}>
<span>{field}</span>
<Handle
type="source"
position={Position.Right}
id={field}
/>
</div>
))}
</div>
);
}Connection validation and cycle prevention
Basic validation with isValidConnection
const isValidConnection = useCallback(
(connection: Connection) => {
// Prevent self-connections
if (connection.source === connection.target) return false;
// Prevent duplicate edges
const edges = getEdges();
const exists = edges.some(
(e) =>
e.source === connection.source &&
e.target === connection.target &&
e.sourceHandle === connection.sourceHandle &&
e.targetHandle === connection.targetHandle,
);
return !exists;
},
[getEdges],
);
<ReactFlow isValidConnection={isValidConnection} ... />Cycle prevention using getOutgoers
import { useCallback } from 'react';
import { getOutgoers, useReactFlow, type Connection } from '@xyflow/react';
function useNoCycles() {
const { getNodes, getEdges } = useReactFlow();
return useCallback(
(connection: Connection) => {
const nodes = getNodes();
const edges = getEdges();
const target = nodes.find((n) => n.id === connection.target);
if (!target) return false;
// Prevent self-connection
if (connection.source === connection.target) return false;
// BFS: walk from target along outgoing edges — if we reach source, it's a cycle
const hasCycle = (node: typeof target, visited = new Set<string>()) => {
if (visited.has(node.id)) return false;
visited.add(node.id);
for (const outgoer of getOutgoers(node, nodes, edges)) {
if (outgoer.id === connection.source) return true;
if (hasCycle(outgoer, visited)) return true;
}
return false;
};
return !hasCycle(target);
},
[getNodes, getEdges],
);
}Usage:
const isValidConnection = useNoCycles();
<ReactFlow isValidConnection={isValidConnection} ... />Connection limits
Limit the number of connections per handle using useNodeConnections:
import { Handle, useNodeConnections, type HandleProps } from '@xyflow/react';
function LimitedHandle({
connectionCount = 1,
...props
}: HandleProps & { connectionCount?: number }) {
const connections = useNodeConnections({
handleType: props.type,
handleId: props.id,
});
return (
<Handle {...props} isConnectable={connections.length < connectionCount} />
);
}Usage in a custom node:
<LimitedHandle type="target" position={Position.Left} connectionCount={1} />
<LimitedHandle type="source" position={Position.Right} connectionCount={3} />Contextual zoom (level-of-detail rendering)
Show different content based on the current zoom level. Use useStore with a selector for performance — the component only re-renders when the zoom threshold is crossed, not on every zoom change:
import { memo } from 'react';
import { Handle, Position, useStore } from '@xyflow/react';
const showDetailSelector = (state: ReactFlowState) => state.transform[2] >= 0.9;
function DetailNode({ data }: NodeProps) {
const showDetail = useStore(showDetailSelector);
return (
<div>
<Handle type="target" position={Position.Left} />
{showDetail ? (
// Full content at high zoom
<div>
<h3>{data.label}</h3>
<p>{data.description}</p>
<ul>{data.items.map((item) => <li key={item}>{item}</li>)}</ul>
</div>
) : (
// Placeholder at low zoom
<div style={{ padding: 10, textAlign: 'center' }}>{data.label}</div>
)}
<Handle type="source" position={Position.Right} />
</div>
);
}
export default memo(DetailNode);Critical: Define the selector outside the component to keep a stable reference. If defined inline, the selector identity changes every render, defeating the optimization.
Collaborative editing
State categorization
Before building multiplayer, decide what to sync:
| Category | Properties | Sync? |
|---|---|---|
| Durable | id, type, data, position, source, target, sourceHandle, targetHandle | Always sync and persist |
| Ephemeral | dragging, resizing, cursor positions | Sync for UX (other users see activity), do not persist |
| Never sync | selected, measured, width/height (computed) | Local per-user state |
Architecture with Yjs (CRDT)
Yjs provides conflict-free replicated data types. Nodes and edges are stored in shared Y.Map and Y.Array structures. Changes merge automatically without a central server.
npm install yjs y-webrtcimport * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
// Create a shared document
const ydoc = new Y.Doc();
const provider = new WebrtcProvider('my-flow-room', ydoc);
// Shared data structures
const yNodes = ydoc.getMap<Node>('nodes');
const yEdges = ydoc.getArray<Edge>('edges');Sync React Flow state with Yjs by observing changes:
import { useEffect, useCallback } from 'react';
import { useReactFlow } from '@xyflow/react';
function useYjsSync(yNodes: Y.Map<Node>, yEdges: Y.Array<Edge>) {
const { setNodes, setEdges } = useReactFlow();
// Yjs -> React Flow: update local state when remote changes arrive
useEffect(() => {
const onNodesChange = () => {
setNodes(Array.from(yNodes.values()));
};
const onEdgesChange = () => {
setEdges(yEdges.toArray());
};
yNodes.observe(onNodesChange);
yEdges.observe(onEdgesChange);
// Initial sync
onNodesChange();
onEdgesChange();
return () => {
yNodes.unobserve(onNodesChange);
yEdges.unobserve(onEdgesChange);
};
}, [yNodes, yEdges, setNodes, setEdges]);
// React Flow -> Yjs: write local changes to shared doc
const updateNode = useCallback(
(id: string, updates: Partial<Node>) => {
const existing = yNodes.get(id);
if (existing) {
yNodes.set(id, { ...existing, ...updates });
}
},
[yNodes],
);
return { updateNode };
}Technology comparison
| Solution | Type | Offline support | Conflict resolution |
|---|---|---|---|
| Yjs | CRDT | Yes | Automatic |
| Automerge | CRDT | Yes | Automatic |
| Liveblocks | Server-authoritative | Limited | Server-managed |
| Supabase Realtime | Server-authoritative | No | Manual (last-write-wins) |
| Convex | Server-authoritative | Optimistic updates | Server-managed |
CRDTs (Yjs, Automerge) are the better fit for flow editors because node position conflicts resolve naturally (both users' moves merge). Server-authoritative solutions require more coordination logic but are simpler to set up with existing databases.
Cursor sharing
Sync other users' cursor positions and smooth them with the perfect-cursors library. Debounce cursor position broadcasts to avoid flooding the network.
Do / Don't
- Do use Zustand + Zundo for undo/redo — it's the most natural fit since React Flow uses Zustand internally.
- Do snapshot state before mutations (on drag start, before delete), not during intermediate states.
- Do regenerate all IDs when pasting copied nodes and edges — duplicate IDs break React Flow.
- Do remap
source/targeton copied edges to the new node IDs. - Do call
updateNodeInternalsafter the render that changes handles, not before. - Do define
useStoreselectors outside component bodies for stable references. - Do categorize state into durable/ephemeral/never-sync before building multiplayer.
- Don't snapshot on every
onNodesChange— intermediate drag positions flood the history. Snapshot on drag start/stop instead. - Don't use
structuredClonein hot paths (every render) — only when creating snapshots. - Don't sync
selectedormeasuredproperties in collaborative editing — these are per-user local state. - Don't forget
className="nodrag"on interactive elements (inputs, buttons) inside custom nodes that useupdateNodeData.
Components and Hooks
When to use this reference
Use this file when working with React Flow's built-in UI components (Background, Controls, MiniMap, Panel, etc.), hooks, or the ReactFlowProvider.
Contents
- ReactFlowProvider
- Built-in components
- Hooks reference
- ReactFlowInstance methods
- Controlled viewport
- Pan to a specific node
- Check viewport initialization
ReactFlowProvider
Required when:
- Using hooks like
useReactFlowoutside the<ReactFlow>component - Multiple flows on the same page
- Client-side routing with flow state
import { ReactFlowProvider } from '@xyflow/react';
function App() {
return (
<ReactFlowProvider>
<Flow />
<Sidebar /> {/* Can use useReactFlow here */}
</ReactFlowProvider>
);
}Rule: The provider must wrap the component containing <ReactFlow>, not be inside it.
Built-in components
Background
Renders a pattern background behind the flow:
import { Background, BackgroundVariant } from '@xyflow/react';
<ReactFlow ...>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} color="#aaa" />
</ReactFlow>| Prop | Type | Default | Description |
|---|---|---|---|
variant | BackgroundVariant | Dots | Dots, Lines, or Cross |
gap | `number \ | [number, number]` | 20 |
size | number | 1 | Dot size or line stroke width |
color | string | — | Pattern color |
lineWidth | number | 1 | Line width (Lines/Cross) |
offset | number | 0 | Pattern offset |
Controls
Renders zoom and fit-view buttons:
import { Controls } from '@xyflow/react';
<ReactFlow ...>
<Controls showZoom showFitView showInteractive position="bottom-left" />
</ReactFlow>| Prop | Type | Default | Description |
|---|---|---|---|
showZoom | boolean | true | Show zoom in/out buttons |
showFitView | boolean | true | Show fit-view button |
showInteractive | boolean | true | Show interactive toggle |
position | PanelPosition | 'bottom-left' | Position on canvas |
onZoomIn | () => void | — | Custom zoom in handler |
onZoomOut | () => void | — | Custom zoom out handler |
onFitView | () => void | — | Custom fit view handler |
onInteractiveChange | (interactive: boolean) => void | — | Toggle handler |
fitViewOptions | FitViewOptions | — | Options for fit view |
orientation | `'horizontal' \ | 'vertical'` | 'vertical' |
ControlButton
Add custom buttons to the Controls panel:
import { Controls, ControlButton } from '@xyflow/react';
<Controls>
<ControlButton onClick={handleSave} title="Save">
<SaveIcon />
</ControlButton>
</Controls>MiniMap
Renders a small overview map:
import { MiniMap } from '@xyflow/react';
<ReactFlow ...>
<MiniMap
nodeStrokeColor="#000"
nodeColor={(node) => node.type === 'input' ? '#0041d0' : '#ff0072'}
maskColor="rgba(0, 0, 0, 0.1)"
pannable
zoomable
/>
</ReactFlow>| Prop | Type | Default | Description |
|---|---|---|---|
nodeColor | `string \ | (node) => string` | '#e2e2e2' |
nodeStrokeColor | `string \ | (node) => string` | 'transparent' |
nodeStrokeWidth | number | 2 | Node stroke width |
nodeBorderRadius | number | 5 | Node border radius |
maskColor | string | 'rgb(240, 240, 240, 0.6)' | Viewport mask color |
maskStrokeColor | string | 'none' | Viewport mask stroke |
maskStrokeWidth | number | 1 | Viewport mask stroke width |
pannable | boolean | false | Pan viewport via minimap |
zoomable | boolean | false | Zoom viewport via minimap |
position | PanelPosition | 'bottom-right' | Position on canvas |
inversePan | boolean | false | Invert pan direction |
zoomStep | number | 10 | Zoom step on scroll |
Panel
Renders a positioned panel on the canvas:
import { Panel } from '@xyflow/react';
<ReactFlow ...>
<Panel position="top-left">
<button onClick={onSave}>Save</button>
<button onClick={onRestore}>Restore</button>
</Panel>
</ReactFlow>| Position | Description |
|---|---|
'top-left' | Top left corner |
'top-center' | Top center |
'top-right' | Top right corner |
'bottom-left' | Bottom left corner |
'bottom-center' | Bottom center |
'bottom-right' | Bottom right corner |
NodeToolbar
Renders a toolbar attached to a node (visible when selected):
import { NodeToolbar, Position } from '@xyflow/react';
function CustomNode({ data }) {
return (
<>
<NodeToolbar position={Position.Top} isVisible>
<button>Copy</button>
<button>Delete</button>
</NodeToolbar>
<div>{data.label}</div>
</>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
position | Position | Position.Top | Side of node |
isVisible | boolean | — | Force visibility (overrides selection) |
offset | number | 10 | Distance from node |
align | `'start' \ | 'center' \ | 'end'` |
NodeResizer / NodeResizeControl
Make nodes resizable:
import { NodeResizer } from '@xyflow/react';
function ResizableNode({ data, selected }) {
return (
<>
<NodeResizer
minWidth={100}
minHeight={30}
isVisible={selected}
color="#ff0071"
/>
<div>{data.label}</div>
</>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
minWidth | number | 10 | Minimum width |
minHeight | number | 10 | Minimum height |
maxWidth | number | Infinity | Maximum width |
maxHeight | number | Infinity | Maximum height |
isVisible | boolean | true | Show resize handles |
color | string | — | Handle color |
handleStyle | CSSProperties | — | Handle styles |
lineStyle | CSSProperties | — | Border line styles |
keepAspectRatio | boolean | false | Maintain aspect ratio |
NodeResizeControl provides a single resize control (e.g., bottom-right only).
ViewportPortal
Renders elements in the viewport coordinate system (affected by zoom and pan, like nodes and edges). Use this to render custom content that moves and scales with the flow:
import { ViewportPortal } from '@xyflow/react';
<ReactFlow ...>
<ViewportPortal>
<div style={{ position: 'absolute', transform: 'translate(100px, 200px)' }}>
This content is positioned in flow coordinates
</div>
</ViewportPortal>
</ReactFlow>Note: For fixed overlays that are not affected by zoom/pan, use <Panel> instead.
Hooks reference
State access hooks
| Hook | Returns | Re-renders on change? |
|---|---|---|
useReactFlow() | ReactFlowInstance | No — reads on demand |
useNodes() | Node[] | Yes — every node change |
useEdges() | Edge[] | Yes — every edge change |
useNodesState(initial) | [nodes, setNodes, onNodesChange] | Yes |
useEdgesState(initial) | [edges, setEdges, onEdgesChange] | Yes |
useViewport() | { x, y, zoom } | Yes — every viewport change |
Node-specific hooks
| Hook | Returns | Description |
|---|---|---|
useNodeId() | string | Current node's ID (use inside custom nodes) |
useNodesData(ids) | NodeData[] | Data for specific node IDs |
useNodesInitialized() | boolean | True after all nodes are measured |
useInternalNode(id) | InternalNode | Internal node with computed bounds |
useUpdateNodeInternals() | (id) => void | Refresh node after handle changes |
Connection hooks
| Hook | Returns | Description |
|---|---|---|
useConnection() | ConnectionState | Active connection state during drag |
useHandleConnections({ type, id? }) | HandleConnection[] | Connections for a specific handle (deprecated — use useNodeConnections) |
useNodeConnections({ handleType?, handleId? }) | NodeConnection[] | All connections for the current node |
Event hooks
| Hook | Parameters | Description |
|---|---|---|
useOnSelectionChange({ onChange }) | { nodes, edges } | Called when selection changes |
useOnViewportChange({ onStart?, onChange?, onEnd? }) | Viewport | Called during viewport changes |
useKeyPress(keyCode) | Returns boolean | Track key press state |
Store hooks
| Hook | Returns | Description |
|---|---|---|
useStore(selector) | Selected state | Subscribe to specific store slices |
useStoreApi() | StoreApi | Direct store access (no subscription) |
useStore selector pattern
Use selectors to avoid re-rendering on unrelated state changes:
// BAD: re-renders on ANY store change
const state = useStore((s) => s);
// GOOD: only re-renders when node count changes
const nodeCount = useStore((s) => s.nodes.length);
// GOOD: custom equality check
const selectedIds = useStore(
(s) => s.nodes.filter((n) => n.selected).map((n) => n.id),
// Zustand shallow comparison
shallow,
);ReactFlowInstance methods
Accessed via useReactFlow(). See references/state-management.md for full patterns.
Node methods
| Method | Signature | Description |
|---|---|---|
getNodes() | () => Node[] | Get all nodes |
getNode(id) | `(id: string) => Node \ | undefined` |
setNodes(nodes) | `(Node[] \ | (Node[]) => Node[]) => void` |
addNodes(nodes) | `(Node \ | Node[]) => void` |
updateNode(id, update) | `(id, Partial<Node> \ | (Node) => Partial<Node>) => void` |
updateNodeData(id, data) | `(id, data \ | (Node) => data) => void` |
deleteElements(opts) | (DeleteElementsOptions) => Promise<DeletedElements> | Delete elements |
Edge methods
| Method | Signature | Description |
|---|---|---|
getEdges() | () => Edge[] | Get all edges |
getEdge(id) | `(id: string) => Edge \ | undefined` |
setEdges(edges) | `(Edge[] \ | (Edge[]) => Edge[]) => void` |
addEdges(edges) | `(Edge \ | Edge[]) => void` |
updateEdge(id, update) | (id, Partial<Edge>) => void | Update edge |
updateEdgeData(id, data) | (id, data) => void | Update edge data |
Viewport methods
| Method | Signature | Description |
|---|---|---|
fitView(options?) | (FitViewOptions?) => Promise<boolean> | Fit viewport to nodes |
zoomIn(options?) | (TransitionOptions?) => Promise<boolean> | Zoom in |
zoomOut(options?) | (TransitionOptions?) => Promise<boolean> | Zoom out |
zoomTo(level, options?) | (number, TransitionOptions?) => Promise<boolean> | Zoom to level |
setViewport(viewport, options?) | (Viewport, TransitionOptions?) => Promise<boolean> | Set viewport |
getViewport() | () => Viewport | Get viewport |
getZoom() | () => number | Get zoom level |
setCenter(x, y, options?) | (x, y, {zoom?, duration?}) => Promise<boolean> | Center on point |
fitBounds(rect, options?) | (Rect, {padding?, duration?}) => Promise<boolean> | Fit to rectangle |
Coordinate conversion
| Method | Signature | Description |
|---|---|---|
screenToFlowPosition(pos) | (XYPosition) => XYPosition | Screen pixels to flow coordinates |
flowToScreenPosition(pos) | (XYPosition) => XYPosition | Flow coordinates to screen pixels |
Intersection methods
| Method | Signature | Description |
|---|---|---|
getIntersectingNodes(node, partially?) | `(Node \ | Rect, boolean?) => Node[]` |
isNodeIntersecting(node, area, partially?) | `(Node \ | Rect, Rect, boolean?) => boolean` |
Utility methods
| Method | Signature | Description |
|---|---|---|
toObject() | () => { nodes, edges, viewport } | Serialize flow state |
getNodesBounds(nodes) | `(Node[] \ | string[]) => Rect` |
getHandleConnections({ type, nodeId, id? }) | Returns HandleConnection[] | Get handle connections |
getNodeConnections({ handleType?, nodeId, handleId? }) | Returns NodeConnection[] | Get node connections |
Controlled viewport
Control the viewport directly through state instead of letting React Flow manage it:
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
<ReactFlow
viewport={viewport}
onViewportChange={setViewport}
...
/>Pan to a specific node
function PanToNode() {
const { getNode, setCenter } = useReactFlow();
const panTo = (nodeId: string) => {
const node = getNode(nodeId);
if (node) {
const x = node.position.x + (node.measured?.width ?? 0) / 2;
const y = node.position.y + (node.measured?.height ?? 0) / 2;
setCenter(x, y, { zoom: 1.5, duration: 500 });
}
};
return <button onClick={() => panTo('node-1')}>Focus Node 1</button>;
}Check viewport initialization
Guard viewport methods until the viewport is ready:
const { viewportInitialized, fitView } = useReactFlow();
const safeFitView = () => {
if (viewportInitialized) fitView({ padding: 0.2, duration: 300 });
};Do / Don't
- Do wrap your flow component tree in
<ReactFlowProvider>when using hooks outside<ReactFlow>. - Do use
useStorewith selectors to minimize re-renders. - Do use
useReactFlowinstead ofuseNodes/useEdgesin event handlers and callbacks. - Don't use
useNodes()oruseEdges()in performance-sensitive components — they re-render on every change. - Don't call hooks outside a
<ReactFlowProvider>context.
Custom Edges
When to use this reference
Use this file when creating custom edge components, adding interactive edge labels, using edge markers (arrows), or building custom SVG paths for edges.
Contents
- Creating a custom edge
- Props injected into custom edges
- Path generation utilities
- Custom SVG paths
- Edge labels with EdgeLabelRenderer
- Edge toolbar
- Edge markers (arrows)
- Edge reconnection
- Default edge options
- Animated edges
Creating a custom edge
Step 1: Define the component
Custom edges receive coordinate and data props from React Flow:
import { BaseEdge, getStraightPath } from '@xyflow/react';
function CustomEdge({ id, sourceX, sourceY, targetX, targetY }) {
const [edgePath] = getStraightPath({ sourceX, sourceY, targetX, targetY });
return <BaseEdge id={id} path={edgePath} />;
}
export default CustomEdge;Step 2: Register the edge type (outside the component!)
const edgeTypes = { custom: CustomEdge };
function App() {
return <ReactFlow edgeTypes={edgeTypes} ... />;
}Step 3: Use the type in edge data
const edges = [
{ id: 'e1', source: '1', target: '2', type: 'custom' },
];Props injected into custom edges
| Prop | Type | Description |
|---|---|---|
id | string | Edge ID |
source | string | Source node ID |
target | string | Target node ID |
sourceX | number | Source handle X coordinate |
sourceY | number | Source handle Y coordinate |
targetX | number | Target handle X coordinate |
targetY | number | Target handle Y coordinate |
sourcePosition | Position | Source handle position (Top/Right/Bottom/Left) |
targetPosition | Position | Target handle position |
sourceHandleId | `string \ | null` |
targetHandleId | `string \ | null` |
data | T | Custom edge data |
selected | boolean | Whether the edge is selected |
animated | boolean | Whether the edge is animated |
markerStart | string | Start marker URL |
markerEnd | string | End marker URL |
style | CSSProperties | Edge styles |
interactionWidth | number | Invisible interaction area width |
label | ReactNode | Edge label |
Path generation utilities
React Flow provides four functions that return [path, labelX, labelY, offsetX, offsetY]:
| Function | Description | Best for |
|---|---|---|
getBezierPath | Smooth bezier curve | Default curved edges |
getSimpleBezierPath | Simpler bezier curve | Less pronounced curves |
getSmoothStepPath | Rounded right-angle path | Step-based layouts |
getStraightPath | Direct straight line | Simple connections |
Usage pattern
import { BaseEdge, getBezierPath } from '@xyflow/react';
function BezierEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }) {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX, sourceY,
targetX, targetY,
sourcePosition,
targetPosition,
});
return <BaseEdge id={id} path={edgePath} />;
}getSmoothStepPath options
const [edgePath] = getSmoothStepPath({
sourceX, sourceY,
targetX, targetY,
sourcePosition,
targetPosition,
borderRadius: 8, // corner rounding (default: 5)
offset: 25, // spacing from nodes
});Custom SVG paths
Build paths manually using SVG path commands:
| Command | Syntax | Description |
|---|---|---|
M | M x y | Move to coordinate |
L | L x y | Line to coordinate |
Q | Q cx cy x y | Quadratic bezier (cx,cy = control point) |
C | C cx1 cy1 cx2 cy2 x y | Cubic bezier |
function WavyEdge({ id, sourceX, sourceY, targetX, targetY }) {
const midX = (sourceX + targetX) / 2;
const midY = (sourceY + targetY) / 2;
const edgePath = `M ${sourceX} ${sourceY} Q ${midX} ${midY - 50} ${targetX} ${targetY}`;
return <BaseEdge id={id} path={edgePath} />;
}Edge labels with EdgeLabelRenderer
For interactive or complex edge labels, use <EdgeLabelRenderer>:
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@xyflow/react';
function LabeledEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data }) {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition,
});
return (
<>
<BaseEdge id={id} path={edgePath} />
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
className="nodrag nopan"
>
<button onClick={() => data?.onDelete?.(id)}>Delete</button>
</div>
</EdgeLabelRenderer>
</>
);
}Key patterns for EdgeLabelRenderer:
- Use
position: absoluteon the label container - Use
transform: translate(-50%, -50%) translate(${labelX}px, ${labelY}px)for positioning - Add
pointerEvents: 'all'to make labels interactive - Add
className="nodrag nopan"for interactive elements
Edge toolbar
<EdgeToolbar> renders a toolbar near the edge (appears when edge is selected):
import { BaseEdge, EdgeToolbar, getBezierPath } from '@xyflow/react';
function ToolbarEdge(props) {
const [edgePath] = getBezierPath(props);
return (
<>
<BaseEdge id={props.id} path={edgePath} />
<EdgeToolbar>
<button>Edit</button>
<button>Delete</button>
</EdgeToolbar>
</>
);
}Edge markers (arrows)
Using built-in markers
import { MarkerType } from '@xyflow/react';
const edges = [
{
id: 'e1',
source: '1',
target: '2',
markerEnd: { type: MarkerType.ArrowClosed },
},
{
id: 'e2',
source: '2',
target: '3',
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#FF0000',
width: 20,
height: 20,
},
markerStart: { type: MarkerType.Arrow },
},
];MarkerType options
| Type | Description |
|---|---|
MarkerType.Arrow | Open arrowhead |
MarkerType.ArrowClosed | Filled arrowhead |
Marker properties
| Property | Type | Description |
|---|---|---|
type | MarkerType | Arrow style |
color | string | Marker color |
width | number | Marker width |
height | number | Marker height |
orient | string | Marker orientation |
strokeWidth | number | Stroke width |
Default marker color
Set a global default via the <ReactFlow> component:
<ReactFlow defaultMarkerColor="#b1b1b7" ... />Edge reconnection
Allow users to detach and reconnect edges by dragging their endpoints:
import { reconnectEdge } from '@xyflow/react';
const onReconnect = useCallback(
(oldEdge, newConnection) => {
setEdges((eds) => reconnectEdge(oldEdge, newConnection, eds));
},
[],
);
<ReactFlow
edgesReconnectable={true}
onReconnect={onReconnect}
reconnectRadius={10}
...
/>Default edge options
Apply defaults to all new edges created via connections:
const defaultEdgeOptions = {
type: 'smoothstep',
animated: true,
style: { stroke: '#FF0000' },
markerEnd: { type: MarkerType.ArrowClosed },
};
<ReactFlow defaultEdgeOptions={defaultEdgeOptions} ... />Animated edges
Dash animation with CSS keyframes
function DashEdge({ id, ...props }: EdgeProps) {
const [edgePath] = getBezierPath(props);
return (
<BaseEdge
id={id}
path={edgePath}
style={{ strokeDasharray: '5 5', animation: 'dashdraw 0.5s linear infinite' }}
/>
);
}@keyframes dashdraw {
to { stroke-dashoffset: -10; }
}Moving circle along path
function MovingCircleEdge({ id, ...props }: EdgeProps) {
const [edgePath] = getBezierPath(props);
return (
<>
<BaseEdge id={id} path={edgePath} />
<circle r="4" fill="#ff0072">
<animateMotion dur="2s" repeatCount="indefinite" path={edgePath} />
</circle>
</>
);
}SVG text along path
function TextPathEdge({ id, data, ...props }: EdgeProps) {
const [edgePath] = getBezierPath(props);
return (
<>
<BaseEdge id={id} path={edgePath} />
<text>
<textPath href={`#${id}`} startOffset="50%" textAnchor="middle" style={{ fontSize: 12 }}>
{data?.label}
</textPath>
</text>
</>
);
}Do / Don't
- Do use
<BaseEdge>and path utilities for standard edge rendering. - Do use
<EdgeLabelRenderer>for interactive edge labels with buttons/inputs. - Do pass
sourcePositionandtargetPositionto path functions for correct curvature. - Don't define
edgeTypesinside a render function. - Don't forget
pointerEvents: 'all'on interactive edge label containers. - Don't forget
className="nodrag nopan"on interactive elements inside edge labels.
Custom Nodes
When to use this reference
Use this file when creating custom node components, configuring handles, or building interactive elements inside nodes. The React Flow team recommends custom nodes over built-in types for any real application.
Contents
- Creating a custom node
- Props injected into custom nodes
- Handle component
- Interactive elements inside nodes
- Drag handles
- Connection mode
Creating a custom node
Step 1: Define the component
Custom nodes receive props automatically injected by React Flow:
import { Handle, Position } from '@xyflow/react';
function ColorPickerNode({ id, data, isConnectable }) {
return (
<div className="color-picker-node">
<Handle type="target" position={Position.Top} isConnectable={isConnectable} />
<div>
<label htmlFor={`color-${id}`}>Color:</label>
<input
id={`color-${id}`}
type="color"
defaultValue={data.color}
className="nodrag"
/>
</div>
<Handle type="source" position={Position.Bottom} isConnectable={isConnectable} />
</div>
);
}
export default ColorPickerNode;Step 2: Register the node type (outside the component!)
// CORRECT: defined outside component body
const nodeTypes = { colorPicker: ColorPickerNode };
function App() {
return <ReactFlow nodeTypes={nodeTypes} ... />;
}// WRONG: causes re-renders and warnings
function App() {
const nodeTypes = { colorPicker: ColorPickerNode }; // re-created every render!
return <ReactFlow nodeTypes={nodeTypes} ... />;
}If node types must be dynamic, use useMemo:
const nodeTypes = useMemo(() => ({ colorPicker: ColorPickerNode }), []);Step 3: Use the type in node data
const nodes = [
{
id: '1',
type: 'colorPicker',
position: { x: 0, y: 0 },
data: { color: '#ff0000' },
},
];Props injected into custom nodes
| Prop | Type | Description |
|---|---|---|
id | string | Node ID |
data | T | The node's data object |
type | string | Node type string |
selected | boolean | Whether the node is selected |
isConnectable | boolean | Whether the node allows connections |
zIndex | number | Current z-index |
positionAbsoluteX | number | Absolute X position |
positionAbsoluteY | number | Absolute Y position |
dragging | boolean | Whether node is being dragged |
dragHandle | string | Drag handle selector |
sourcePosition | Position | Default source handle position |
targetPosition | Position | Default target handle position |
parentId | string | Parent node ID (sub-flows) |
width | number | Measured width |
height | number | Measured height |
Handle component
The <Handle> component creates connection points on nodes.
Basic usage
import { Handle, Position } from '@xyflow/react';
<Handle type="target" position={Position.Top} />
<Handle type="source" position={Position.Bottom} />Handle props
| Prop | Type | Default | Description |
|---|---|---|---|
type | `'source' \ | 'target'` | — |
position | Position | — | Side of node (Top, Right, Bottom, Left) |
id | string | — | Required when multiple handles of same type |
isConnectable | boolean | true | Allow connections |
isConnectableStart | boolean | true | Allow starting connections from this handle |
isConnectableEnd | boolean | true | Allow ending connections at this handle |
onConnect | (connection) => void | — | Called when connection is made to this handle |
style | CSSProperties | — | Inline styles |
className | string | — | CSS class |
Multiple handles
When a node has multiple handles of the same type, each must have a unique id:
function MultiHandleNode() {
return (
<div>
<Handle type="source" position={Position.Right} id="output-a" />
<Handle type="source" position={Position.Right} id="output-b" style={{ top: '75%' }} />
<Handle type="target" position={Position.Left} id="input" />
</div>
);
}Reference handles in edges using sourceHandle and targetHandle:
const edges = [
{ id: 'e1', source: 'node1', sourceHandle: 'output-a', target: 'node2' },
{ id: 'e2', source: 'node1', sourceHandle: 'output-b', target: 'node3' },
];Custom handle appearance
Wrap any element with <Handle> and hide the default appearance:
<Handle
type="source"
position={Position.Right}
style={{ background: 'none', border: 'none', width: '1.5em', height: '1.5em' }}
>
<PlusIcon style={{ pointerEvents: 'none', fontSize: '1.5em' }} />
</Handle>Critical: Set pointerEvents: 'none' on children so the handle receives click/drag events.
Hiding handles
Use visibility: hidden or opacity: 0 — never display: none:
/* CORRECT */
.react-flow__handle { opacity: 0; }
/* WRONG — breaks dimension calculation */
.react-flow__handle { display: none; }Dynamic handles
When programmatically adding or removing handles, refresh node internals:
import { useUpdateNodeInternals } from '@xyflow/react';
function DynamicNode({ id }) {
const updateNodeInternals = useUpdateNodeInternals();
const addHandle = () => {
// ... add handle to state
updateNodeInternals(id);
};
}Handle validation styling
Handles receive CSS classes during connection:
| Class | When |
|---|---|
connecting | Connection line is over the handle |
valid | Connection would be valid |
.react-flow__handle.connecting { background: orange; }
.react-flow__handle.valid { background: green; }Interactive elements inside nodes
Interactive elements (inputs, buttons, selects, textareas) need special class names to prevent conflicts with node dragging and viewport zoom:
| Class | Effect |
|---|---|
nodrag | Prevents node dragging when interacting with element |
nowheel | Prevents viewport zoom on scroll (for scrollable elements) |
nopan | Prevents viewport panning |
<input type="text" className="nodrag" />
<select className="nodrag"><option>A</option></select>
<div className="nodrag nowheel" style={{ overflow: 'auto', maxHeight: 200 }}>
{/* scrollable content */}
</div>Drag handles
Restrict dragging to a specific element using the dragHandle property:
const nodes = [
{
id: '1',
type: 'custom',
data: { label: 'Drag me by the header' },
dragHandle: '.drag-handle',
position: { x: 0, y: 0 },
},
];
function CustomNode({ data }) {
return (
<div>
<div className="drag-handle">Drag here</div>
<div>Content (not draggable)</div>
</div>
);
}Connection mode
By default, source handles only connect to target handles (connectionMode="strict"). Set connectionMode="loose" on <ReactFlow> to allow connections between any handle types:
<ReactFlow connectionMode="loose" ... />Do / Don't
- Do use custom nodes for anything beyond the simplest prototypes.
- Do apply
nodragto all interactive form elements inside nodes. - Do give unique
ids to multiple handles of the same type. - Do use
pointerEvents: 'none'on custom handle child elements. - Don't define
nodeTypesinside a render function. - Don't use
display: noneto hide handles. - Don't forget to call
updateNodeInternalsafter dynamic handle changes.
E2E Testing with Playwright
When to use this reference
Use this file when writing Playwright end-to-end tests for React Flow applications: selecting nodes and edges, testing drag interactions, verifying viewport behavior, asserting connections, or setting up test infrastructure for flow-based UIs.
Contents
- Playwright setup for React Flow
- React Flow selector reference
- Test fixture: controlled flow component
- Node tests
- Edge tests
- Connection tests
- Viewport tests
- Toolbar and overlay tests
- Wait and stability strategies
- Helper utilities
- Playwright configuration tips
- Do / Don't
Playwright setup for React Flow
Minimal playwright.config.ts with a webServer block for a Vite or Next.js dev server:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});For Next.js, change the command and port:
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},React Flow selector reference
CSS class selectors
| Selector | Element |
|---|---|
.react-flow | Root container |
.react-flow__renderer | Main renderer wrapper |
.react-flow__viewport | Viewport (transform applied here) |
.react-flow__pane | Background pane (receives pan/click events) |
.react-flow__nodes | Node container |
.react-flow__node | Individual node |
.react-flow__node-default | Default node type |
.react-flow__node-input | Input node type |
.react-flow__node-output | Output node type |
.react-flow__node-group | Group node type |
.react-flow__edges | Edge container (SVG) |
.react-flow__edge | Individual edge |
.react-flow__edge-path | Edge path element |
.react-flow__edge-interaction | Edge interaction area (wider invisible path for click targets) |
.react-flow__connection | Active connection line |
.react-flow__connectionline | Connection line path |
.react-flow__handle | Handle element |
.react-flow__handle-top | Handle positioned at top |
.react-flow__handle-right | Handle positioned at right |
.react-flow__handle-bottom | Handle positioned at bottom |
.react-flow__handle-left | Handle positioned at left |
.react-flow__minimap | MiniMap component |
.react-flow__controls | Controls component |
.react-flow__background | Background component |
.react-flow__panel | Panel component |
.react-flow__node-toolbar | NodeToolbar component |
.react-flow__nodesselection | Multi-selection box |
.react-flow__selection | Selection rectangle |
Data attributes
| Attribute | Used on | Example |
|---|---|---|
data-id | Nodes, edges | [data-id="node-1"] |
data-nodeid | Handles | [data-nodeid="node-1"] |
data-handleid | Handles | [data-handleid="output-a"] |
data-handlepos | Handles | [data-handlepos="right"] |
data-testid | Custom elements | [data-testid="custom-node"] |
Combined selector patterns
// Select a specific node
page.locator('.react-flow__node[data-id="node-1"]');
// Select a specific edge
page.locator('.react-flow__edge[data-id="edge-1-2"]');
// Select the source handle on a specific node
page.locator('[data-nodeid="node-1"].react-flow__handle-bottom');
// Select a specific handle by ID on a node
page.locator('[data-nodeid="node-1"][data-handleid="output-a"]');
// Select all selected nodes
page.locator('.react-flow__node.selected');
// Select all selected edges
page.locator('.react-flow__edge.selected');
// Count all nodes
page.locator('.react-flow__node');
// The viewport element (for reading transforms)
page.locator('.react-flow__viewport');Test fixture: controlled flow component
A reusable test component ensures deterministic starting state:
import { useCallback, useState } from 'react';
import {
ReactFlow,
addEdge,
applyNodeChanges,
applyEdgeChanges,
type Node,
type Edge,
type OnNodesChange,
type OnEdgesChange,
type OnConnect,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const initialNodes: Node[] = [
{ id: 'node-1', position: { x: 0, y: 0 }, data: { label: 'Node 1' } },
{ id: 'node-2', position: { x: 250, y: 100 }, data: { label: 'Node 2' } },
{ id: 'node-3', position: { x: 250, y: 250 }, data: { label: 'Node 3' } },
];
const initialEdges: Edge[] = [
{ id: 'edge-1-2', source: 'node-1', target: 'node-2' },
];
export default function TestFlow() {
const [nodes, setNodes] = useState(initialNodes);
const [edges, setEdges] = useState(initialEdges);
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[],
);
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[],
);
const onConnect: OnConnect = useCallback(
(connection) => setEdges((eds) => addEdge(connection, eds)),
[],
);
return (
<div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
/>
</div>
);
}Critical: The container div must have explicit dimensions. Using fitView ensures all nodes are visible regardless of screen size, making tests deterministic.
Node tests
Select a node
import { test, expect } from '@playwright/test';
test('select a node by clicking', async ({ page }) => {
await page.goto('/');
const node = page.locator('.react-flow__node[data-id="node-1"]');
await expect(node).toBeAttached();
await node.click();
await expect(node).toHaveClass(/selected/);
});Drag a node
Critical: Use { steps: 5 } (or more) in page.mouse.move. Single-step moves do not trigger React Flow's drag handlers because React Flow requires multiple mousemove events.
test('drag a node changes its position', async ({ page }) => {
await page.goto('/');
const node = page.locator('.react-flow__node[data-id="node-1"]');
await expect(node).toBeAttached();
const beforeBox = await node.boundingBox();
expect(beforeBox).not.toBeNull();
// Drag from center of node
const startX = beforeBox!.x + beforeBox!.width / 2;
const startY = beforeBox!.y + beforeBox!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 100, startY + 50, { steps: 5 });
await page.mouse.up();
const afterBox = await node.boundingBox();
expect(afterBox!.x).toBeGreaterThan(beforeBox!.x);
expect(afterBox!.y).toBeGreaterThan(beforeBox!.y);
});Delete a node
test('delete a selected node with Backspace', async ({ page }) => {
await page.goto('/');
const nodes = page.locator('.react-flow__node');
await expect(nodes).toHaveCount(3);
const node = page.locator('.react-flow__node[data-id="node-1"]');
await node.click();
await page.keyboard.press('Backspace');
await expect(nodes).toHaveCount(2);
});Verify node CSS classes and visibility
test('custom node has expected classes', async ({ page }) => {
await page.goto('/');
const node = page.locator('.react-flow__node[data-id="node-1"]');
await expect(node).toBeVisible();
await expect(node).toHaveClass(/react-flow__node-default/);
});Edge tests
Select an edge
Edges are thin SVG paths — click the wider interaction area:
test('select an edge', async ({ page }) => {
await page.goto('/');
const edge = page.locator('.react-flow__edge[data-id="edge-1-2"]');
await expect(edge).toBeAttached();
// Click the interaction area (wider invisible path)
const interactionPath = edge.locator('.react-flow__edge-interaction');
await interactionPath.click();
await expect(edge).toHaveClass(/selected/);
});Check edge markers
test('edge has arrow marker', async ({ page }) => {
await page.goto('/');
const edgePath = page.locator(
'.react-flow__edge[data-id="edge-1-2"] .react-flow__edge-path',
);
await expect(edgePath).toHaveAttribute('marker-end', /url/);
});Delete an edge
test('delete a selected edge', async ({ page }) => {
await page.goto('/');
const edges = page.locator('.react-flow__edge');
await expect(edges).toHaveCount(1);
const interactionPath = page
.locator('.react-flow__edge[data-id="edge-1-2"]')
.locator('.react-flow__edge-interaction');
await interactionPath.click();
await page.keyboard.press('Backspace');
await expect(edges).toHaveCount(0);
});Count edges after connection
test('new edge appears after connection', async ({ page }) => {
await page.goto('/');
const edges = page.locator('.react-flow__edge');
await expect(edges).toHaveCount(1);
// ... perform connection (see Connection tests) ...
await expect(edges).toHaveCount(2);
});Connection tests
Handle-to-handle connection
Critical: Use { steps: 5 } in page.mouse.move — single-step moves skip React Flow's internal event processing and the connection will not register.
test('connect two nodes via handles', async ({ page }) => {
await page.goto('/');
const edges = page.locator('.react-flow__edge');
await expect(edges).toHaveCount(1);
// Source handle on node-1 (bottom)
const sourceHandle = page.locator(
'[data-nodeid="node-1"].react-flow__handle-bottom',
);
// Target handle on node-3 (top)
const targetHandle = page.locator(
'[data-nodeid="node-3"].react-flow__handle-top',
);
const sourceBBox = await sourceHandle.boundingBox();
const targetBBox = await targetHandle.boundingBox();
await page.mouse.move(
sourceBBox!.x + sourceBBox!.width / 2,
sourceBBox!.y + sourceBBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
targetBBox!.x + targetBBox!.width / 2,
targetBBox!.y + targetBBox!.height / 2,
{ steps: 5 },
);
await page.mouse.up();
await expect(edges).toHaveCount(2);
});Connection line visibility during drag
test('connection line visible while dragging', async ({ page }) => {
await page.goto('/');
const sourceHandle = page.locator(
'[data-nodeid="node-1"].react-flow__handle-bottom',
);
const sourceBBox = await sourceHandle.boundingBox();
await page.mouse.move(
sourceBBox!.x + sourceBBox!.width / 2,
sourceBBox!.y + sourceBBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
sourceBBox!.x + 100,
sourceBBox!.y + 100,
{ steps: 5 },
);
const connectionLine = page.locator('.react-flow__connection');
await expect(connectionLine).toBeVisible();
await page.mouse.up();
});Viewport tests
Pan the viewport
test('pan by dragging the pane', async ({ page }) => {
await page.goto('/');
const viewport = page.locator('.react-flow__viewport');
await expect(viewport).toBeAttached();
const beforeTransform = await getTransform(page);
// Drag on the pane (empty area)
const pane = page.locator('.react-flow__pane');
const paneBox = await pane.boundingBox();
const startX = paneBox!.x + paneBox!.width / 2;
const startY = paneBox!.y + paneBox!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 150, startY + 100, { steps: 5 });
await page.mouse.up();
const afterTransform = await getTransform(page);
expect(afterTransform.x).toBeGreaterThan(beforeTransform.x);
expect(afterTransform.y).toBeGreaterThan(beforeTransform.y);
});Zoom with mouse wheel
test('zoom in with mouse wheel', async ({ page }) => {
await page.goto('/');
const viewport = page.locator('.react-flow__viewport');
await expect(viewport).toBeAttached();
const beforeTransform = await getTransform(page);
const pane = page.locator('.react-flow__pane');
const paneBox = await pane.boundingBox();
await page.mouse.move(
paneBox!.x + paneBox!.width / 2,
paneBox!.y + paneBox!.height / 2,
);
// Negative deltaY = zoom in
await page.mouse.wheel(0, -200);
// Wait for zoom animation to settle
await page.waitForTimeout(300);
const afterTransform = await getTransform(page);
expect(afterTransform.scale).toBeGreaterThan(beforeTransform.scale);
});Zoom constraints (minZoom / maxZoom)
test('zoom respects minZoom and maxZoom', async ({ page }) => {
// Assumes the test fixture has minZoom={0.5} maxZoom={2}
await page.goto('/');
const pane = page.locator('.react-flow__pane');
const paneBox = await pane.boundingBox();
await page.mouse.move(
paneBox!.x + paneBox!.width / 2,
paneBox!.y + paneBox!.height / 2,
);
// Zoom in aggressively
for (let i = 0; i < 20; i++) {
await page.mouse.wheel(0, -200);
}
await page.waitForTimeout(300);
const maxTransform = await getTransform(page);
expect(maxTransform.scale).toBeLessThanOrEqual(2);
// Zoom out aggressively
for (let i = 0; i < 40; i++) {
await page.mouse.wheel(0, 200);
}
await page.waitForTimeout(300);
const minTransform = await getTransform(page);
expect(minTransform.scale).toBeGreaterThanOrEqual(0.5);
});fitView
test('fitView makes all nodes visible', async ({ page }) => {
await page.goto('/');
// With fitView on the fixture, all nodes should be within the viewport
const nodes = page.locator('.react-flow__node');
const count = await nodes.count();
for (let i = 0; i < count; i++) {
await expect(nodes.nth(i)).toBeVisible();
}
});Toolbar and overlay tests
Toolbar visibility on node selection
test('NodeToolbar appears when node is selected', async ({ page }) => {
await page.goto('/');
const toolbar = page.locator('.react-flow__node-toolbar');
// Toolbar hidden before selection
await expect(toolbar).not.toBeVisible();
// Select a node
const node = page.locator('.react-flow__node[data-id="node-1"]');
await node.click();
await expect(toolbar).toBeVisible();
});Toolbar positioning relative to node
test('toolbar is positioned above the node', async ({ page }) => {
await page.goto('/');
const node = page.locator('.react-flow__node[data-id="node-1"]');
await node.click();
const toolbar = page.locator('.react-flow__node-toolbar');
await expect(toolbar).toBeVisible();
const nodeBox = await node.boundingBox();
const toolbarBox = await toolbar.boundingBox();
// Toolbar should be above the node (lower y value)
expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(nodeBox!.y);
});Wait and stability strategies
| Strategy | When to use |
|---|---|
await expect(locator).toBeAttached() | Wait for element to exist in the DOM (e.g., after page load) |
await expect(locator).toBeVisible() | Wait for element to be visible (e.g., toolbar after selection) |
await expect(locator).toHaveCount(n) | Wait for exact number of elements (e.g., edge count after connection) |
await expect(locator).toHaveClass(/selected/) | Wait for class change (e.g., after clicking a node) |
await expect(locator).toHaveAttribute(attr, val) | Wait for attribute value (e.g., edge markers) |
page.waitForTimeout(ms) | Last resort — only for animations with no observable state change (e.g., zoom settle) |
Prefer assertion-based waits (expect with auto-retry) over waitForTimeout. Assertion-based waits are faster (they resolve as soon as the condition is met) and more reliable (they don't depend on timing).
Use toHaveCount instead of manual counting:
// WRONG — does not auto-retry
const count = await page.locator('.react-flow__node').count();
expect(count).toBe(3);
// CORRECT — auto-retries until condition met or timeout
await expect(page.locator('.react-flow__node')).toHaveCount(3);Helper utilities
getTransform
Extracts translateX, translateY, and scale from the viewport's CSS transform. Based on the pattern used in the xyflow test suite:
async function getTransform(page: import('@playwright/test').Page) {
return page.locator('.react-flow__viewport').evaluate((el) => {
const style = window.getComputedStyle(el);
const matrix = new DOMMatrix(style.transform);
return {
x: matrix.m41,
y: matrix.m42,
scale: matrix.a,
};
});
}Usage:
const { x, y, scale } = await getTransform(page);
expect(scale).toBeGreaterThan(1); // zoomed indragFromTo
Convenience helper for mouse drag operations:
async function dragFromTo(
page: import('@playwright/test').Page,
from: { x: number; y: number },
to: { x: number; y: number },
steps = 5,
) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps });
await page.mouse.up();
}Usage:
const nodeBox = await node.boundingBox();
await dragFromTo(
page,
{ x: nodeBox!.x + nodeBox!.width / 2, y: nodeBox!.y + nodeBox!.height / 2 },
{ x: nodeBox!.x + nodeBox!.width / 2 + 100, y: nodeBox!.y + nodeBox!.height / 2 + 50 },
);getBoundingBoxCenter
Get the center point of an element for mouse operations:
async function getBoundingBoxCenter(locator: import('@playwright/test').Locator) {
const box = await locator.boundingBox();
if (!box) throw new Error('Element not found or not visible');
return {
x: box.x + box.width / 2,
y: box.y + box.height / 2,
};
}Usage:
const sourceCenter = await getBoundingBoxCenter(sourceHandle);
const targetCenter = await getBoundingBoxCenter(targetHandle);
await dragFromTo(page, sourceCenter, targetCenter);Playwright configuration tips
CI configuration
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
},
});Traces on first retry capture screenshots, DOM snapshots, and network logs — invaluable for debugging flaky CI failures.
Debugging locally
Run tests in headed mode to watch execution:
npx playwright test --headedPause execution at a specific point:
await page.pause(); // opens Playwright InspectorStep through locators interactively:
npx playwright test --debugViewport size
React Flow needs screen space. Set a reasonable viewport:
use: {
viewport: { width: 1280, height: 720 },
},Test isolation
Each test gets a fresh page by default. If your tests share a fixture page, use test.describe with beforeEach:
test.describe('node interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(page.locator('.react-flow__node')).toHaveCount(3);
});
test('select node', async ({ page }) => { /* ... */ });
test('drag node', async ({ page }) => { /* ... */ });
});Do / Don't
- Do use
{ steps: 5 }(or more) inpage.mouse.movefor drag operations — single-step moves don't trigger React Flow's drag handlers. - Do use
getTransform()withDOMMatrixto read viewport position and scale — never parse CSS transform strings manually. - Do use combined selectors like
.react-flow__node[data-id="node-1"]for targeting specific elements. - Do use
fitViewin test fixtures for deterministic starting positions. - Do use assertion-based waits (
toHaveCount,toBeAttached,toBeVisible) overwaitForTimeout. - Do use relative comparisons (greater than, less than) for position assertions rather than exact pixel values — viewport size and
fitViewcalculations vary. - Do give the container explicit dimensions (
100vwx100vh) in test fixtures. - Don't use
waitForTimeoutas a primary wait strategy — it's slow and flaky. - Don't assert exact pixel coordinates — use bounding box comparisons (before vs. after) instead.
- Don't click edge paths directly — use
.react-flow__edge-interactionfor reliable edge clicking. - Don't forget to
await expect(...).toBeAttached()before readingboundingBox()— the element may not be in the DOM yet.
Fundamentals
When to use this reference
Use this file when setting up a new React Flow project, building a first flow, or understanding the core node/edge data model.
Contents
- Installation
- Minimal flow setup
- Node object structure
- Edge object structure
- Built-in node types
- Built-in edge types
- Controlled vs. uncontrolled flows
- The viewport
Installation
npm install @xyflow/reactAlways import the stylesheet — without it, nodes and edges will not render correctly:
import '@xyflow/react/dist/style.css';For custom styling frameworks (Tailwind, styled-components), import only base styles:
import '@xyflow/react/dist/base.css';Minimal flow setup
import { ReactFlow, Background, Controls } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const initialNodes = [
{ id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1' }, type: 'input' },
{ id: '2', position: { x: 200, y: 100 }, data: { label: 'Node 2' } },
];
const initialEdges = [
{ id: 'e1-2', source: '1', target: '2' },
];
export default function App() {
return (
<div style={{ width: '100%', height: '100vh' }}>
<ReactFlow nodes={initialNodes} edges={initialEdges} fitView>
<Background />
<Controls />
</ReactFlow>
</div>
);
}Critical: The parent <div> must have explicit width and height. Without this, nothing renders.
Node object structure
Required fields:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier |
position | { x: number, y: number } | Position on the canvas |
data | Record<string, unknown> | Arbitrary data passed to the node component |
Key optional fields:
| Field | Type | Default | Description |
|---|---|---|---|
type | string | 'default' | Node type key matching nodeTypes |
hidden | boolean | false | Hide node from canvas |
selected | boolean | false | Selection state |
draggable | boolean | true | Whether node can be dragged |
selectable | boolean | true | Whether node can be selected |
connectable | boolean | true | Whether handles accept connections |
deletable | boolean | true | Whether node can be deleted |
dragHandle | string | — | CSS selector for drag handle element |
parentId | string | — | Parent node ID for sub-flows |
extent | `CoordinateExtent \ | 'parent'` | — |
expandParent | boolean | false | Auto-expand parent when dragged to edge |
zIndex | number | — | Stacking order |
sourcePosition | Position | Position.Bottom | Default source handle position |
targetPosition | Position | Position.Top | Default target handle position |
style | CSSProperties | — | Inline styles for the node wrapper |
className | string | — | CSS class for the node wrapper |
ariaLabel | string | — | Accessibility label |
Note: width and height are read-only (calculated by React Flow). Use initialWidth and initialHeight to set dimensions before measurement.
Edge object structure
Required fields:
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier |
source | string | Source node ID |
target | string | Target node ID |
Key optional fields:
| Field | Type | Default | Description |
|---|---|---|---|
type | string | 'default' | Edge type key matching edgeTypes |
sourceHandle | `string \ | null` | — |
targetHandle | `string \ | null` | — |
animated | boolean | false | Animate the edge |
hidden | boolean | false | Hide edge from canvas |
selected | boolean | false | Selection state |
selectable | boolean | true | Whether edge can be selected |
deletable | boolean | true | Whether edge can be deleted |
reconnectable | `boolean \ | HandleType` | true |
label | ReactNode | — | Edge label content |
labelStyle | CSSProperties | — | Label text styles |
labelShowBg | boolean | true | Show background behind label |
labelBgStyle | CSSProperties | — | Label background styles |
labelBgPadding | [number, number] | — | Label background padding |
labelBgBorderRadius | number | — | Label background border radius |
markerStart | EdgeMarkerType | — | Start marker (arrow, etc.) |
markerEnd | EdgeMarkerType | — | End marker (arrow, etc.) |
interactionWidth | number | 20 | Invisible interaction area width |
style | CSSProperties | — | Edge SVG styles |
className | string | — | CSS class |
zIndex | number | — | Stacking order |
Built-in node types
| Type | Description |
|---|---|
'default' | One source handle (bottom), one target handle (top) |
'input' | One source handle only (starting node) |
'output' | One target handle only (ending node) |
'group' | No handles, used as a container for sub-flows |
Built-in edge types
| Type | Description |
|---|---|
'default' | Bezier curve |
'straight' | Straight line |
'step' | Right-angle step path |
'smoothstep' | Rounded step path |
'simplebezier' | Simple bezier curve |
Controlled vs. uncontrolled flows
Controlled (recommended for any non-trivial app)
You manage nodes and edges in state and handle all changes:
import { useState, useCallback } from 'react';
import { ReactFlow, applyNodeChanges, applyEdgeChanges, addEdge } from '@xyflow/react';
export default function Flow() {
const [nodes, setNodes] = useState(initialNodes);
const [edges, setEdges] = useState(initialEdges);
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[],
);
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[],
);
const onConnect = useCallback(
(connection) => setEdges((eds) => addEdge(connection, eds)),
[],
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
/>
);
}Uncontrolled (simple demos only)
React Flow manages state internally. Use defaultNodes / defaultEdges instead of nodes / edges:
<ReactFlow
defaultNodes={initialNodes}
defaultEdges={initialEdges}
defaultEdgeOptions={{ animated: true }}
fitView
/>To modify an uncontrolled flow programmatically, use the useReactFlow hook:
const { addNodes } = useReactFlow();
addNodes({ id: 'new', position: { x: 0, y: 0 }, data: { label: 'New' } });The viewport
The viewport is the visible area of the canvas. Users can pan (drag) and zoom (scroll/pinch).
Key viewport props on <ReactFlow>:
| Prop | Default | Description |
|---|---|---|
defaultViewport | { x: 0, y: 0, zoom: 1 } | Initial viewport position |
fitView | false | Auto-fit all nodes on mount |
minZoom | 0.5 | Minimum zoom level |
maxZoom | 2 | Maximum zoom level |
preventScrolling | true | Prevent page scroll over flow |
translateExtent | [[-Infinity, -Infinity], [Infinity, Infinity]] | Pan boundary |
nodeExtent | — | Node placement boundary |
snapToGrid | false | Snap nodes to grid on drag |
snapGrid | [15, 15] | Grid size for snapping |
Do / Don't
- Do import
@xyflow/react/dist/style.cssin every project. - Do set explicit width/height on the parent container.
- Do use controlled flows for applications with user interaction.
- Don't define
nodeTypesoredgeTypesinside a component render function. - Don't mutate nodes or edges directly — always create new objects.
- Don't use
defaultNodes/defaultEdgesalongsidenodes/edges— pick one pattern.
Interactivity
When to use this reference
Use this file when configuring event handlers, connection validation, selection behavior, keyboard shortcuts, or any user interaction with the flow.
Contents
- Core interaction handlers
- Default interactive capabilities
- Node event handlers
- Edge event handlers
- Connection event handlers
- Pane event handlers
- Selection event handlers
- Viewport event handlers
- Deletion handlers
- Interaction toggle props
- Keyboard configuration
- Connection line customization
- Custom connection line
- Drag and drop from external source
- Error handling
Core interaction handlers
A controlled flow needs three handlers for basic interactivity:
import { useCallback, useState } from 'react';
import { ReactFlow, applyNodeChanges, applyEdgeChanges, addEdge } from '@xyflow/react';
function Flow() {
const [nodes, setNodes] = useState(initialNodes);
const [edges, setEdges] = useState(initialEdges);
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[],
);
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[],
);
const onConnect = useCallback(
(connection) => setEdges((eds) => addEdge(connection, eds)),
[],
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
/>
);
}Without onNodesChange, nodes snap back after dragging. Without onConnect, connection lines appear but edges are never created.
Default interactive capabilities
With the three core handlers wired up, users get:
- Selectable nodes and edges (click)
- Draggable nodes
- Connectable nodes (drag from handles)
- Multi-selection via Shift + click
- Multi-selection via Shift + drag (selection box)
- Remove selected elements via Backspace/Delete
Node event handlers
| Prop | Signature | Description |
|---|---|---|
onNodeClick | (event, node) => void | Node clicked |
onNodeDoubleClick | (event, node) => void | Node double-clicked |
onNodeContextMenu | (event, node) => void | Node right-clicked |
onNodeDragStart | (event, node, nodes) => void | Drag starts |
onNodeDrag | (event, node, nodes) => void | During drag |
onNodeDragStop | (event, node, nodes) => void | Drag ends |
onNodeMouseEnter | (event, node) => void | Mouse enters node |
onNodeMouseMove | (event, node) => void | Mouse moves over node |
onNodeMouseLeave | (event, node) => void | Mouse leaves node |
onNodesDelete | (nodes) => void | Nodes deleted |
onNodesChange | (changes) => void | Any node change (required for controlled flow) |
Edge event handlers
| Prop | Signature | Description |
|---|---|---|
onEdgeClick | (event, edge) => void | Edge clicked |
onEdgeDoubleClick | (event, edge) => void | Edge double-clicked |
onEdgeContextMenu | (event, edge) => void | Edge right-clicked |
onEdgeMouseEnter | (event, edge) => void | Mouse enters edge |
onEdgeMouseMove | (event, edge) => void | Mouse moves over edge |
onEdgeMouseLeave | (event, edge) => void | Mouse leaves edge |
onEdgesDelete | (edges) => void | Edges deleted |
onEdgesChange | (changes) => void | Any edge change (required for controlled flow) |
Connection event handlers
| Prop | Signature | Description |
|---|---|---|
onConnect | (connection) => void | Two nodes successfully connected |
onConnectStart | (event, params) => void | Connection drag begins |
onConnectEnd | (event, connectionState) => void | Connection drag ends (valid or not) |
onClickConnectStart | (event, params) => void | Click-based connection starts |
onClickConnectEnd | (event) => void | Click-based connection ends |
isValidConnection | (connection) => boolean | Validate before allowing connection |
Connection validation
const isValidConnection = useCallback(
(connection) => {
// Prevent self-connections
if (connection.source === connection.target) return false;
// Prevent duplicate edges
const exists = edges.some(
(e) => e.source === connection.source && e.target === connection.target,
);
return !exists;
},
[edges],
);
<ReactFlow isValidConnection={isValidConnection} ... />Handling dropped connections (connecting to empty space)
const onConnectEnd = useCallback(
(event, connectionState) => {
if (!connectionState.isValid) {
// Connection was dropped on empty canvas — create a new node here
const { clientX, clientY } = 'changedTouches' in event ? event.changedTouches[0] : event;
const position = screenToFlowPosition({ x: clientX, y: clientY });
const newNode = {
id: `node-${Date.now()}`,
position,
data: { label: 'New Node' },
};
setNodes((nds) => [...nds, newNode]);
setEdges((eds) => [
...eds,
{ id: `e-${Date.now()}`, source: connectionState.fromNode.id, target: newNode.id },
]);
}
},
[screenToFlowPosition],
);Pane event handlers
| Prop | Signature | Description |
|---|---|---|
onPaneClick | (event) => void | Click on empty canvas |
onPaneContextMenu | (event) => void | Right-click on empty canvas |
onPaneScroll | (event) => void | Scroll over canvas |
onPaneMouseMove | (event) => void | Mouse move over canvas |
onPaneMouseEnter | (event) => void | Mouse enters canvas |
onPaneMouseLeave | (event) => void | Mouse leaves canvas |
Selection event handlers
| Prop | Signature | Description |
|---|---|---|
onSelectionChange | ({ nodes, edges }) => void | Selection changes |
onSelectionDragStart | (event, nodes) => void | Selection box drag starts |
onSelectionDrag | (event, nodes) => void | During selection box drag |
onSelectionDragStop | (event, nodes) => void | Selection box drag ends |
onSelectionContextMenu | (event, nodes) => void | Right-click on selection |
Viewport event handlers
| Prop | Signature | Description |
|---|---|---|
onMoveStart | (event, viewport) => void | Pan/zoom starts |
onMove | (event, viewport) => void | During pan/zoom |
onMoveEnd | (event, viewport) => void | Pan/zoom ends |
Deletion handlers
| Prop | Signature | Description |
|---|---|---|
onDelete | ({ nodes, edges }) => void | After elements deleted |
onBeforeDelete | `({ nodes, edges }) => Promise<boolean \ | { nodes: Node[]; edges: Edge[] }>` |
Preventing deletion of specific nodes
const onBeforeDelete = useCallback(async ({ nodes, edges }) => {
// Prevent deleting the root node
const hasRoot = nodes.some((n) => n.id === 'root');
if (hasRoot) return false;
return true;
}, []);Or set deletable: false on individual nodes/edges:
{ id: 'root', data: { label: 'Root' }, position: { x: 0, y: 0 }, deletable: false }Interaction toggle props
| Prop | Type | Default | Description |
|---|---|---|---|
nodesDraggable | boolean | true | All nodes draggable |
nodesConnectable | boolean | true | All nodes connectable |
nodesFocusable | boolean | true | Tab key cycles focus between nodes |
edgesFocusable | boolean | true | Tab key cycles focus between edges |
elementsSelectable | boolean | true | Click to select |
autoPanOnConnect | boolean | true | Viewport pans during connection |
autoPanOnNodeDrag | boolean | true | Viewport pans during drag |
panOnDrag | `boolean \ | number[]` | true |
panOnScroll | boolean | false | Scroll to pan instead of zoom |
zoomOnScroll | boolean | true | Scroll wheel zooms |
zoomOnPinch | boolean | true | Pinch gesture zooms |
zoomOnDoubleClick | boolean | true | Double-click zooms |
selectNodesOnDrag | boolean | true | Select nodes when dragging |
selectionOnDrag | boolean | false | Drag creates selection box without modifier key |
selectionMode | `'full' \ | 'partial'` | 'full' |
connectOnClick | boolean | true | Click handles to connect (not just drag) |
connectionMode | `'strict' \ | 'loose'` | 'strict' |
elevateNodesOnSelect | boolean | true | Raise z-index of selected nodes |
elevateEdgesOnSelect | boolean | false | Raise z-index of selected edges |
Keyboard configuration
| Prop | Default | Description |
|---|---|---|
deleteKeyCode | 'Backspace' | Delete selected elements |
selectionKeyCode | 'Shift' | Hold to draw selection box |
multiSelectionKeyCode | 'Meta' (Mac) / 'Control' (Win) | Hold to multi-select |
zoomActivationKeyCode | 'Meta' (Mac) / 'Control' (Win) | Hold to enable zoom |
panActivationKeyCode | 'Space' | Hold to enable panning |
Set any key code to null to disable that keyboard shortcut.
Connection line customization
| Prop | Type | Description |
|---|---|---|
connectionLineStyle | CSSProperties | Style for the in-progress connection line |
connectionLineType | ConnectionLineType | Path type ('default', 'straight', 'step', 'smoothstep', 'simplebezier') |
connectionRadius | number | Snap radius around target handles |
connectionLineComponent | React.ComponentType | Custom connection line component |
Custom connection line
Override the default connection line shown while dragging:
import { ConnectionLineComponentProps, getSmoothStepPath } from '@xyflow/react';
function CustomConnectionLine({
fromX, fromY, fromPosition,
toX, toY, toPosition,
connectionStatus,
}: ConnectionLineComponentProps) {
const [path] = getSmoothStepPath({
sourceX: fromX, sourceY: fromY, sourcePosition: fromPosition,
targetX: toX, targetY: toY, targetPosition: toPosition,
});
return (
<g>
<path
d={path}
fill="none"
stroke={connectionStatus === 'valid' ? '#22c55e' : '#ef4444'}
strokeWidth={2}
strokeDasharray="5 5"
/>
</g>
);
}
<ReactFlow connectionLineComponent={CustomConnectionLine} ... />Drag and drop from external source
Add nodes by dragging from a sidebar:
function DnDFlow() {
const { screenToFlowPosition, addNodes } = useReactFlow();
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback((event: DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow');
if (!type) return;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
addNodes({ id: `${Date.now()}`, type, position, data: { label: `${type} node` } });
}, [screenToFlowPosition, addNodes]);
return <ReactFlow onDragOver={onDragOver} onDrop={onDrop} ... />;
}
// Sidebar
function Sidebar() {
const onDragStart = (event: DragEvent, nodeType: string) => {
event.dataTransfer.setData('application/reactflow', nodeType);
event.dataTransfer.effectAllowed = 'move';
};
return (
<aside>
<div draggable onDragStart={(e) => onDragStart(e, 'custom')}>Custom Node</div>
</aside>
);
}Error handling
const onError = useCallback((code: string, message: string) => {
console.error(`React Flow Error [${code}]:`, message);
}, []);
<ReactFlow onError={onError} ... />Do / Don't
- Do wire up all three core handlers (
onNodesChange,onEdgesChange,onConnect) for controlled flows. - Do memoize event handler callbacks with
useCallback. - Do use
isValidConnectionfor connection rules rather than post-hoc cleanup. - Don't forget that
onConnectEndfires regardless of connection validity — checkconnectionState.isValid. - Don't set keyboard codes to
undefined— usenullto disable them.
Layouting
When to use this reference
Use this file when positioning nodes with layout algorithms, creating sub-flows with parent-child relationships, or integrating external layout libraries like dagre, elkjs, or d3.
Contents
- Layout library comparison
- Dagre integration
- ELK integration
- D3-Hierarchy integration
- D3-Force integration
- Sub-flows (parent-child nodes)
- Layout on initial render
- useAutoLayout hook (dagre)
- Animated layout transitions
Layout library comparison
React Flow does not include built-in layout algorithms. Use an external library:
| Library | Best for | Dynamic sizes | Sub-flows | Edge routing | Bundle size |
|---|---|---|---|---|---|
| dagre | Tree/DAG with minimal config | Yes | Partial | No | Small |
| elkjs | Complex, highly configurable layouts | Yes | Yes | Yes | Large (~1.4MB) |
| d3-hierarchy | Single-root tree structures | No (uniform) | No | No | Small |
| d3-force | Physics-based, organic layouts | Yes | No | No | Small |
Dagre integration
Best for tree-shaped graphs with straightforward requirements.
import dagre from '@dagrejs/dagre';
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
function getLayoutedElements(nodes, edges, direction = 'TB') {
const isHorizontal = direction === 'LR';
dagreGraph.setGraph({ rankdir: direction });
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: node.measured?.width ?? 172,
height: node.measured?.height ?? 36,
});
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - (node.measured?.width ?? 172) / 2,
y: nodeWithPosition.y - (node.measured?.height ?? 36) / 2,
},
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
};
});
return { nodes: layoutedNodes, edges };
}Using dagre layout
function LayoutFlow() {
const [nodes, setNodes] = useState(initialNodes);
const [edges, setEdges] = useState(initialEdges);
const onLayout = useCallback(
(direction) => {
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
nodes,
edges,
direction,
);
setNodes([...layoutedNodes]);
setEdges([...layoutedEdges]);
},
[nodes, edges],
);
return (
<ReactFlow nodes={nodes} edges={edges} fitView>
<Panel position="top-right">
<button onClick={() => onLayout('TB')}>Vertical</button>
<button onClick={() => onLayout('LR')}>Horizontal</button>
</Panel>
</ReactFlow>
);
}Note: Dagre centers nodes by default. Subtract half the width/height to get the top-left origin React Flow expects.
ELK integration
Best for complex graphs needing edge routing and advanced layout options.
import ELK from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
const elkOptions = {
'elk.algorithm': 'layered',
'elk.layered.spacing.nodeNodeBetweenLayers': '100',
'elk.spacing.nodeNode': '80',
};
async function getLayoutedElements(nodes, edges, options = {}) {
const graph = {
id: 'root',
layoutOptions: { ...elkOptions, ...options },
children: nodes.map((node) => ({
id: node.id,
width: node.measured?.width ?? 150,
height: node.measured?.height ?? 50,
targetPosition: 'top',
sourcePosition: 'bottom',
})),
edges: edges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
})),
};
const layoutedGraph = await elk.layout(graph);
const layoutedNodes = nodes.map((node) => {
const layoutedNode = layoutedGraph.children?.find((n) => n.id === node.id);
return {
...node,
position: { x: layoutedNode?.x ?? 0, y: layoutedNode?.y ?? 0 },
};
});
return { nodes: layoutedNodes, edges };
}Note: ELK runs asynchronously. Handle the layout in a useEffect or event handler with await.
D3-Hierarchy integration
Best for tree structures with a single root node.
import { stratify, tree } from 'd3-hierarchy';
function getLayoutedElements(nodes, edges) {
const hierarchy = stratify()
.id((d) => d.id)
.parentId((d) => edges.find((e) => e.target === d.id)?.source);
const root = hierarchy(nodes);
const layout = tree().nodeSize([200, 100]);
layout(root);
return {
nodes: root.descendants().map((d) => ({
...d.data,
position: { x: d.x, y: d.y },
})),
edges,
};
}Limitation: Requires single root, all nodes must be reachable, uniform node sizes.
D3-Force integration
Best for organic, physics-based layouts with interactive simulation.
import { forceSimulation, forceLink, forceManyBody, forceX, forceY } from 'd3-force';
function useLayoutedElements() {
const { getNodes, getEdges, setNodes } = useReactFlow();
return useCallback(() => {
const nodes = getNodes();
const edges = getEdges();
const simulation = forceSimulation(nodes)
.force('link', forceLink(edges).id((d) => d.id).distance(100))
.force('charge', forceManyBody().strength(-200))
.force('x', forceX().strength(0.05))
.force('y', forceY().strength(0.05));
simulation.on('end', () => {
setNodes(
nodes.map((node) => ({
...node,
position: { x: node.x, y: node.y },
})),
);
});
simulation.alpha(1).restart();
}, [getNodes, getEdges, setNodes]);
}Sub-flows (parent-child nodes)
Creating a sub-flow
Set parentId on child nodes. Children are positioned relative to their parent's top-left corner:
const nodes = [
// Parent must come BEFORE children in the array
{
id: 'group-1',
type: 'group',
position: { x: 0, y: 0 },
style: { width: 400, height: 300 },
data: {},
},
{
id: 'child-1',
parentId: 'group-1',
position: { x: 20, y: 50 }, // relative to parent
data: { label: 'Child Node' },
},
{
id: 'child-2',
parentId: 'group-1',
position: { x: 200, y: 50 },
data: { label: 'Another Child' },
extent: 'parent', // restrict movement to parent bounds
},
];Critical rules for sub-flows
1. Parent first: Parent nodes must appear before their children in the nodes array. 2. Relative positioning: Child position is relative to parent's top-left corner. 3. Movement: Children move with their parent. Without extent: 'parent', children can be dragged outside. 4. Edge rendering: Edges connected to child nodes render above nodes (not below like normal edges). 5. Parent dimensions: Set explicit style.width and style.height on parent nodes.
Constraining children
// Constrain child to parent bounds
{ extent: 'parent' }
// Auto-expand parent when child is dragged to edge
{ expandParent: true }The group node type
The group type is a convenience — it has no handles and renders as a container:
{ id: 'g1', type: 'group', position: { x: 0, y: 0 }, style: { width: 400, height: 300 }, data: {} }Any node type can be a parent. Use custom types for parents that need handles or custom rendering.
Connecting sub-flow nodes externally
Child nodes can have edges to nodes outside their parent group. This creates connections between the sub-flow and the outer flow.
Layout on initial render
To layout nodes after they've been measured (so you have accurate dimensions):
import { useNodesInitialized } from '@xyflow/react';
function Flow() {
const nodesInitialized = useNodesInitialized();
useEffect(() => {
if (nodesInitialized) {
// Nodes are measured — now apply layout
const { nodes: layouted } = getLayoutedElements(nodes, edges);
setNodes(layouted);
// Optionally fit view after layout
setTimeout(() => fitView(), 0);
}
}, [nodesInitialized]);
}useAutoLayout hook (dagre)
Reusable hook that auto-layouts on initialization and exposes a runLayout function:
import { useCallback, useEffect, useRef } from 'react';
import { useReactFlow, useNodesInitialized } from '@xyflow/react';
import dagre from '@dagrejs/dagre';
interface UseAutoLayoutOptions {
direction?: 'TB' | 'BT' | 'LR' | 'RL';
nodesep?: number;
ranksep?: number;
}
export function useAutoLayout(options: UseAutoLayoutOptions = {}) {
const { direction = 'TB', nodesep = 50, ranksep = 50 } = options;
const { getNodes, getEdges, setNodes, fitView } = useReactFlow();
const nodesInitialized = useNodesInitialized();
const layoutApplied = useRef(false);
const runLayout = useCallback(() => {
const nodes = getNodes();
const edges = getEdges();
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: direction, nodesep, ranksep });
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
g.setNode(node.id, {
width: node.measured?.width ?? 172,
height: node.measured?.height ?? 36,
});
});
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
dagre.layout(g);
const layouted = nodes.map((node) => {
const pos = g.node(node.id);
const w = node.measured?.width ?? 172;
const h = node.measured?.height ?? 36;
return { ...node, position: { x: pos.x - w / 2, y: pos.y - h / 2 } };
});
setNodes(layouted);
window.requestAnimationFrame(() => fitView({ duration: 200 }));
}, [direction, nodesep, ranksep, getNodes, getEdges, setNodes, fitView]);
useEffect(() => {
if (nodesInitialized && !layoutApplied.current) {
runLayout();
layoutApplied.current = true;
}
}, [nodesInitialized, runLayout]);
return { runLayout };
}Usage:
function Flow() {
const { runLayout } = useAutoLayout({ direction: 'LR', ranksep: 100 });
return (
<>
<button onClick={runLayout}>Re-layout</button>
<ReactFlow ... />
</>
);
}Animated layout transitions
Add smooth position changes when re-laying out:
.react-flow__node {
transition: transform 300ms ease-out;
}Do / Don't
- Do use dagre for quick tree layouts with minimal configuration.
- Do use elkjs when you need edge routing or complex layout options.
- Do ensure parent nodes appear before children in the
nodesarray. - Do set explicit dimensions on parent/group nodes.
- Do apply layout after nodes are measured (use
useNodesInitialized). - Don't expect React Flow to layout nodes automatically — it only handles rendering and interaction.
- Don't mix layout libraries without understanding their constraints (e.g., d3-hierarchy needs a single root).
Migration Guide
When to use this reference
Use this file when upgrading a project from the legacy reactflow package (v11 or earlier) to @xyflow/react (v12+), or from react-flow-renderer (v10 or earlier) to current.
Contents
- Package rename
- Import changes
- CSS import changes
- Immutable state updates
- Custom node props renamed
- TypeScript type changes
- Hooks changes
- Step-by-step checklist
Package rename
The package name changed across major versions:
| Version | Package name | Import style |
|---|---|---|
| v10 and earlier | react-flow-renderer | import ReactFlow from 'react-flow-renderer' |
| v11 | reactflow | import ReactFlow from 'reactflow' |
| v12+ (current) | @xyflow/react | import { ReactFlow } from '@xyflow/react' |
To migrate:
# Remove the old package
npm uninstall reactflow
# or: npm uninstall react-flow-renderer
# Install the new package
npm install @xyflow/reactImport changes
v11 used a default export. v12 uses named exports:
// v11 (old)
import ReactFlow, { Background, Controls, MiniMap } from 'reactflow';
// v12 (new)
import { ReactFlow, Background, Controls, MiniMap } from '@xyflow/react';All subpackage imports (@reactflow/core, @reactflow/background, etc.) are consolidated into @xyflow/react. Remove any subpackage dependencies.
CSS import changes
// v11 (old)
import 'reactflow/dist/style.css';
// v12 (new)
import '@xyflow/react/dist/style.css';
// or for custom styling frameworks (Tailwind, styled-components):
import '@xyflow/react/dist/base.css';Immutable state updates
v11 tolerated mutations when updating nodes. v12 requires immutable updates — mutations are not detected:
// v11 (old) — mutations worked
setNodes((currentNodes) =>
currentNodes.map((node) => {
node.hidden = true; // mutation
return node;
}),
);
// v12 (new) — must create new objects
setNodes((currentNodes) =>
currentNodes.map((node) => ({
...node,
hidden: true,
})),
);This applies everywhere: setNodes, setEdges, onNodesChange handlers, Zustand stores, etc.
Custom node props renamed
The position props passed to custom nodes were renamed:
// v11 (old)
function CustomNode({ xPos, yPos }) {
// ...
}
// v12 (new)
function CustomNode({ positionAbsoluteX, positionAbsoluteY }) {
// ...
}TypeScript type changes
v12 simplified the generic type system for nodes and edges. Instead of passing data generics to every hook, define a union type and use it everywhere:
// v11 (old) — generic on each usage
import { Node } from 'reactflow';
type MyNode = Node<{ label: string; value: number }>;
// v12 (new) — discriminated union with type tag
import { type Node } from '@xyflow/react';
type NumberNode = Node<{ value: number }, 'number'>;
type TextNode = Node<{ text: string }, 'text'>;
type AppNode = NumberNode | TextNode;Apply the union type to hooks and callbacks:
const { getNodes, getEdges } = useReactFlow<AppNode, AppEdge>();
const onNodesChange: OnNodesChange<AppNode> = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[],
);Hooks changes
| v11 | v12 | Notes |
|---|---|---|
useNodesState | Still available | Works the same way |
useEdgesState | Still available | Works the same way |
useHandleConnections | useNodeConnections | Renamed |
useReactFlow().project() | useReactFlow().screenToFlowPosition() | Renamed |
useReactFlow().setTransform() | useReactFlow().setViewport() | Renamed (from v10) |
New hooks in v12 (no v11 equivalent):
useNodesData(nodeIds)— subscribe to data changes on specific nodesuseUpdateNodeInternals()— trigger handle recalculation after dynamic changes
Step-by-step checklist
1. Replace the package: npm uninstall reactflow && npm install @xyflow/react 2. Find-and-replace all imports:
from 'reactflow'→from '@xyflow/react'import ReactFlow(default) →import { ReactFlow }(named)'reactflow/dist/style.css'→'@xyflow/react/dist/style.css'
3. Remove any @reactflow/* subpackage dependencies 4. Update custom node components: xPos → positionAbsoluteX, yPos → positionAbsoluteY 5. Audit all setNodes / setEdges calls for mutations — convert to spread-based immutable updates 6. Update TypeScript types to use the new Node<Data, Type> union pattern 7. Rename deprecated hooks: useHandleConnections → useNodeConnections 8. Test that the flow renders correctly (check for blank canvas = missing CSS or container dimensions)
Do / Don't
- Do run a project-wide search for
'reactflow'and'react-flow-renderer'to catch all imports. - Do update TypeScript generics to the new discriminated union pattern — it's more powerful and type-safe.
- Do check for node mutations in Zustand stores — these are the most common source of silent breakage after migration.
- Don't keep both
reactflowand@xyflow/reactinstalled — this causes duplicate React Flow instances and Zustand context errors. - Don't use
@reactflow/*subpackages — everything is consolidated in@xyflow/react.
Related skills
FAQ
What is the react-flow skill for?
react-flow is an agent skill that helps developers implement React Flow-based node-graph interfaces—nodes, edges, handles, and canvas layouts—for workflow editors and diagram UIs in React applications.
When should you use react-flow?
Use react-flow when building or refactoring interactive node-based canvases in React, such as pipeline visualizers or workflow editors, instead of custom SVG graph implementations.