Devtools and Debugging

This is an ongoing experiment on implementing our own React Flow devtools. While we are working on the actual package, we'd love to hear about your feedback and ideas on [Discord](https://discord.gg/Bqt6xrs) or via mail at info@xyflow.com.

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’s onNodesChange handler 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.

Example: learn/devtools
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!