Devtools and Debugging
React Flow can often seem like a magic black box, but in reality you can reveal quite a lot about its internal state if you know where to look. In this guide we will show you three different ways to reveal the internal state of your flow:
- A
<ViewportLogger />component that shows the current position and zoom level of the viewport. - A
<NodeInspector />component that reveals the state of each node. - A
<ChangeLogger />that wraps your flow’sonNodesChangehandler and logs each change as it is dispatched.
While we find these tools useful for making sure React Flow is working properly, you might also find them useful for debugging your applications as your flows and their interactions become more complex.
App.tsx
import { useCallback } from 'react';
import {
ReactFlow,
addEdge,
useEdgesState,
useNodesState,
type Edge,
type OnConnect,
type Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import DevTools from './Devtools';
const initNodes: Node[] = [
{
id: '1a',
type: 'input',
data: { label: 'Node 1' },
position: { x: 250, y: 5 },
},
{
id: '2a',
data: { label: 'Node 2' },
position: { x: 100, y: 120 },
},
{
id: '3a',
data: { label: 'Node 3' },
position: { x: 400, y: 120 },
},
];
const initEdges: Edge[] = [
{ id: 'e1-2', source: '1a', target: '2a' },
{ id: 'e1-3', source: '1a', target: '3a' },
];
const fitViewOptions = { padding: 0.5 };
function Flow() {
const [nodes, , onNodesChange] = useNodesState(initNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges);
const onConnect: OnConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges],
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
fitViewOptions={fitViewOptions}
colorMode="system"
>
<DevTools />
</ReactFlow>
);
}
export default Flow;ChangeLogger.tsx
import { useEffect, useRef, useState } from 'react';
import {
useStore,
useStoreApi,
type OnNodesChange,
type NodeChange,
} from '@xyflow/react';
type ChangeLoggerProps = {
color?: string;
limit?: number;
};
type ChangeInfoProps = {
change: NodeChange;
};
function ChangeInfo({ change }: ChangeInfoProps) {
const id = 'id' in change ? change.id : '-';
const { type } = change;
return (
<div style={{ marginBottom: 4 }}>
<div>node id: {id}</div>
<div>
{type === 'add' ? JSON.stringify(change.item, null, 2) : null}
{type === 'dimensions'
? `dimensions: ${change.dimensions?.width} × ${change.dimensions?.height}`
: null}
{type === 'position'
? `position: ${change.position?.x.toFixed(1)}, ${change.position?.y.toFixed(1)}`
: null}
{type === 'remove' ? 'remove' : null}
{type === 'select' ? (change.selected ? 'select' : 'unselect') : null}
</div>
</div>
);
}
export default function ChangeLogger({ limit = 20 }: ChangeLoggerProps) {
const [changes, setChanges] = useState<NodeChange[]>([]);
const onNodesChangeIntercepted = useRef(false);
const onNodesChange = useStore((s) => s.onNodesChange);
const store = useStoreApi();
useEffect(() => {
if (!onNodesChange || onNodesChangeIntercepted.current) {
return;
}
onNodesChangeIntercepted.current = true;
const userOnNodesChange = onNodesChange;
const onNodesChangeLogger: OnNodesChange = (changes) => {
userOnNodesChange(changes);
setChanges((oldChanges) => [...changes, ...oldChanges].slice(0, limit));
};
store.setState({ onNodesChange: onNodesChangeLogger });
}, [onNodesChange, limit]);
return (
<div className="react-flow__devtools-changelogger">
<div className="react-flow__devtools-title">Change Logger</div>
{changes.length === 0 ? (
<>no changes triggered</>
) : (
changes.map((change, index) => <ChangeInfo key={index} change={change} />)
)}
</div>
);
}Devtools.tsx
import {
useState,
type Dispatch,
type SetStateAction,
type ReactNode,
type HTMLAttributes,
} from 'react';
import { Panel } from '@xyflow/react';
import NodeInspector from './NodeInspector';
import ChangeLogger from './ChangeLogger';
import ViewportLogger from './ViewportLogger';
export default function DevTools() {
const [nodeInspectorActive, setNodeInspectorActive] = useState(true);
const [changeLoggerActive, setChangeLoggerActive] = useState(true);
const [viewportLoggerActive, setViewportLoggerActive] = useState(true);
return (
<div className="react-flow__devtools">
<Panel position="top-left">
<DevToolButton
setActive={setNodeInspectorActive}
active={nodeInspectorActive}
title="Toggle Node Inspector"
>
Node Inspector
</DevToolButton>
<DevToolButton
setActive={setChangeLoggerActive}
active={changeLoggerActive}
title="Toggle Change Logger"
>
Change Logger
</DevToolButton>
<DevToolButton
setActive={setViewportLoggerActive}
active={viewportLoggerActive}
title="Toggle Viewport Logger"
>
Viewport Logger
</DevToolButton>
</Panel>
{changeLoggerActive && <ChangeLogger />}
{nodeInspectorActive && <NodeInspector />}
{viewportLoggerActive && <ViewportLogger />}
</div>
);
}
function DevToolButton({
active,
setActive,
children,
...rest
}: {
active: boolean;
setActive: Dispatch<SetStateAction<boolean>>;
children: ReactNode;
} & HTMLAttributes<HTMLButtonElement>) {
return (
<button
onClick={() => setActive((a) => !a)}
className={active ? 'active' : ''}
{...rest}
>
{children}
</button>
);
}NodeInspector.tsx
import { useNodes, ViewportPortal, useReactFlow, type XYPosition } from '@xyflow/react';
export default function NodeInspector() {
const { getInternalNode } = useReactFlow();
const nodes = useNodes();
return (
<ViewportPortal>
<div className="react-flow__devtools-nodeinspector">
{nodes.map((node) => {
const internalNode = getInternalNode(node.id);
if (!internalNode) {
return null;
}
const absPosition = internalNode?.internals.positionAbsolute;
return (
<NodeInfo
key={node.id}
id={node.id}
selected={!!node.selected}
type={node.type || 'default'}
position={node.position}
absPosition={absPosition}
width={node.measured?.width ?? 0}
height={node.measured?.height ?? 0}
data={node.data}
/>
);
})}
</div>
</ViewportPortal>
);
}
type NodeInfoProps = {
id: string;
type: string;
selected: boolean;
position: XYPosition;
absPosition: XYPosition;
width?: number;
height?: number;
data: any;
};
function NodeInfo({
id,
type,
selected,
position,
absPosition,
width,
height,
data,
}: NodeInfoProps) {
if (!width || !height) {
return null;
}
return (
<div
className="react-flow__devtools-nodeinfo"
style={{
position: 'absolute',
transform: `translate(${absPosition.x}px, ${absPosition.y + height}px)`,
width: width * 2,
}}
>
<div>id: {id}</div>
<div>type: {type}</div>
<div>selected: {selected ? 'true' : 'false'}</div>
<div>
position: {position.x.toFixed(1)}, {position.y.toFixed(1)}
</div>
<div>
dimensions: {width} × {height}
</div>
<div>data: {JSON.stringify(data, null, 2)}</div>
</div>
);
}ViewportLogger.tsx
import { Panel, useStore } from '@xyflow/react';
export default function ViewportLogger() {
const viewport = useStore(
(s) =>
`x: ${s.transform[0].toFixed(2)}, y: ${s.transform[1].toFixed(
2,
)}, zoom: ${s.transform[2].toFixed(2)}`,
);
return <Panel position="bottom-left">{viewport}</Panel>;
}index.css
html,
body {
margin: 0;
font-family: sans-serif;
}
#app {
width: 100vw;
height: 100vh;
}
.react-flow__devtools {
--border-radius: 4px;
--highlight-color: rgba(238, 58, 115, 1);
--font: monospace, sans-serif;
border-radius: var(--border-radius);
font-size: 11px;
font-family: var(--font);
}
.react-flow__devtools button {
background: white;
border: none;
padding: 5px 15px;
color: #222;
font-weight: bold;
font-size: 12px;
cursor: pointer;
font-family: var(--font);
background-color: #f4f4f4;
border-right: 1px solid #ddd;
}
.dark .react-flow__devtools button {
background-color: #1a1a1a;
color: #eee;
border-right-color: #333;
}
.react-flow__devtools button:hover {
background: var(--highlight-color);
opacity: 0.8;
color: white;
}
.react-flow__devtools button.active {
background: var(--highlight-color);
color: white;
}
.react-flow__devtools button:first-child {
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
.react-flow__devtools button:last-child {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
border-right: none;
}
.react-flow__devtools-changelogger {
pointer-events: none;
position: relative;
top: 50px;
left: 20px;
font-family: var(--font);
}
.react-flow__devtools-title {
font-weight: bold;
margin-bottom: 5px;
}
.react-flow__devtools-nodeinspector {
pointer-events: none;
font-family: monospace, sans-serif;
font-size: 10px;
}
.react-flow__devtools-nodeinfo {
top: 5px;
}
.dark .react-flow__devtools button:hover {
opacity: 1;
}
.dark .react-flow__devtools-changelogger {
color: #ccc;
}
.dark .react-flow__devtools-nodeinspector {
color: #ccc;
}
.dark .react-flow__panel {
color: #ccc;
}index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Flow Example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>index.tsx
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const container = document.querySelector('#app');
const root = createRoot(container);
root.render(<App />);We encourage you to copy any or all of the components from this example into your own projects and modify them to suit your needs: each component works independently!
Node Inspector
The <NodeInspector /> component makes use of our
useNodes hook to access all the nodes in the flow.
Typically we discourage using this hook because it will trigger a re-render any time any
of your nodes change, but that’s exactly what makes it so useful for debugging!
The width and height properties are added to each node by React Flow after it has
measured the node’s dimensions. We pass those dimensions, as well as other information
like the node’s id and type, to a custom <NodeInfo /> component.
We make use of the <ViewportPortal />
component to let us render the inspector into React Flow’s viewport. That means it’s
content will be positioned and transformed along with the rest of the flow as the user
pans and zooms.
Change Logger
Any change to your nodes and edges that originates from React Flow itself is communicated
to you through the onNodesChange and onEdgesChange callbacks. If you are working with
a controlled flow (that means you’re managing the nodes and edges yourself), you need to
apply those changes to your state in order to keep everything in sync.
The <ChangeLogger /> component wraps your user-provided onNodesChange handler with a
custom function that intercepts and logs each change as it is dispatched. We can do this
by using the useStore and
useStoreApi hooks to access the store and and then
update React Flow’s internal state accordingly. These two hooks give you powerful access
to React Flow’s internal state and methods.
Beyond debugging, using the <ChangeLogger /> can be a great way to learn more about how
React Flow works and get you thinking about the different functionality you can build on
top of each change.
You can find documentation on the NodeChange and
EdgeChange types in the API reference.
Viewport Logger
The <ViewportLogger /> is the simplest example of what state you can pull out of React
Flow’s store if you know what to look for. The state of the viewport is stored internally
under the transform key (a name we inherited from
d3-zoom). This component extracts the x, y,
and zoom components of the transform and renders them into a
<Panel /> component.
Let us know what you think
As mentioned above, if you have any feedback or ideas on how to improve the devtools, please let us know on Discord or via mail at info@xyflow.com. If you build your own devtools using these ideas, we’d love to hear about it!