An interactive node graph hovering over a drafting table with a large red Maine Coon cat resting nearby.

React Flow: Wiring Up Node-Based Editors Without the Tears

The Orange Cat
The Orange Cat

If you have ever needed to build a workflow builder, a no-code editor, an ETL pipeline designer, or a mind-mapping tool, you already know the trap: the demo looks like a few draggable boxes connected by lines, but the real work is an infinite pannable canvas, draggable nodes that do not overlap, edges that re-route as things move, connection interactions, selection, keyboard shortcuts, and performance with thousands of elements. React Flow gives you all of that out of the box while staying, in its own words, infinitely customizable.

The important thing to understand up front is that React Flow is a UI library, not a graph-theory engine. It renders and makes interactive whatever node and edge data you give it. You own the data and, optionally, the layout. That single design decision is why it composes so cleanly with the rest of a React app, including your state library, your undo/redo stack, and your persistence layer.

What You Get in the Box

React Flow ships a focused set of building blocks rather than a kitchen sink:

  • A pannable, zoomable, infinite canvas with a fitView option to auto-frame your content.
  • MiniMap for a bird's-eye overview of the graph and the current viewport.
  • Controls with zoom in/out, fit-view, and lock buttons.
  • Background rendering dot, line, or cross grid patterns.
  • Panel for positioning arbitrary UI like toolbars and legends over the canvas.
  • Custom nodes and edges that are real React components, so you can drop in your own design system and form inputs.
  • Connection interactions with validation hooks, so users drag from a handle to create an edge.
  • Selection including single, multi, and box selection with keyboard deletion.
  • A TypeScript-first API with strong generics for typing your node and edge data.
  • Helper hooks like useReactFlow, useNodesState, useEdgesState, and useNodesData.

The dependency footprint is refreshingly small: classcat, zustand, and @xyflow/system, the framework-agnostic core shared with Svelte Flow. Internally it leans on Zustand for state, but you never have to touch that.

A Quick Note on the Name

If you have used this library before, you might know it as reactflow. The project rebranded to xyflow, an umbrella covering React Flow (@xyflow/react) and Svelte Flow (@xyflow/svelte). Versions 11 and earlier ship as reactflow with a default export. Version 12 and later ship as @xyflow/react with named exports. The legacy package still works and still gets millions of downloads, but new development happens on @xyflow/react, and that is what you should install for any new project. If you are migrating, the headline changes are the package name, the named exports, and a new CSS path. There is an official migration guide if you need the full list.

Getting It Installed

React Flow lives in a single package. Install it with npm:

npm install @xyflow/react

Or with yarn:

yarn add @xyflow/react

There is one easy-to-miss step: you must import the stylesheet, or your nodes and edges will render broken and unstyled. Import it once, near your app entry or in the file that hosts the flow:

import '@xyflow/react/dist/style.css';

Your First Flow

The mental model that matters most is that React Flow is controlled. You hold the nodes and edges in React state and pass them in; React Flow tells you about user changes through callbacks, and you apply those changes back to state. The useNodesState and useEdgesState hooks wire up that loop for you, which is the easiest way to avoid mistakes.

import { useCallback } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  addEdge,
  useNodesState,
  useEdgesState,
  type Node,
  type Edge,
  type Connection,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

const initialNodes: Node[] = [
  { id: 'n1', position: { x: 0, y: 0 }, data: { label: 'Node 1' } },
  { id: 'n2', position: { x: 0, y: 100 }, data: { label: 'Node 2' } },
];

const initialEdges: Edge[] = [{ id: 'n1-n2', source: 'n1', target: 'n2' }];

export default function Flow() {
  const [nodes, , onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

  const onConnect = useCallback(
    (params: Connection) => setEdges((eds) => addEdge(params, eds)),
    [setEdges],
  );

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        fitView
      >
        <Background />
        <Controls />
        <MiniMap />
      </ReactFlow>
    </div>
  );
}

A node is just a plain object: an id, a position with x and y, and a data object that holds whatever payload your node renders from. An edge connects two nodes by their ids. The onConnect callback fires when a user drags a new connection, and addEdge is a small helper that returns a new edges array with the connection appended.

One detail trips up nearly everyone the first time: the container must have an explicit width and height. The canvas is absolutely positioned, so a parent with zero dimensions renders an invisible flow. If you ever see a blank screen, check the container size first.

Driving State by Hand

The hooks are convenient, but sometimes you want to own the state yourself, for example when nodes live in a larger store or you need to intercept every change. The hooks are just sugar over the applyNodeChanges and applyEdgeChanges helpers, and you can call them directly:

import { useState, useCallback } from 'react';
import {
  applyNodeChanges,
  type Node,
  type NodeChange,
} from '@xyflow/react';

const [nodes, setNodes] = useState<Node[]>(initialNodes);

const onNodesChange = useCallback(
  (changes: NodeChange[]) =>
    setNodes((snapshot) => applyNodeChanges(changes, snapshot)),
  [],
);

Each change describes a single user action such as a position update, a selection toggle, or a removal. Because you are the one applying them, you can validate, log, or transform changes before they land in state, which is exactly where undo/redo and autosave hooks fit naturally. Keep in mind that if you pass nodes and edges but forget to wire up onNodesChange and onEdgesChange, your nodes will not drag or select at all. Controlled state is not optional for interactivity.

Nodes That Are Real Components

The reason teams reach for React Flow over lower-level alternatives is that nodes are genuine React components. You register a component under a key in nodeTypes, then reference that key as a node's type. React Flow wraps your component in an interactive container and injects props like selection and drag state. Inside, you render whatever JSX you want, plus <Handle> components, which are the connection points where edges attach.

import { useCallback } from 'react';
import {
  ReactFlow,
  Handle,
  Position,
  type NodeProps,
} from '@xyflow/react';

function TextUpdaterNode({ data }: NodeProps) {
  const onChange = useCallback(
    (evt: React.ChangeEvent<HTMLInputElement>) =>
      console.log(evt.target.value),
    [],
  );

  return (
    <div className="text-updater-node">
      <Handle type="target" position={Position.Top} />
      <div>
        <label htmlFor="text">Text:</label>
        <input id="text" name="text" onChange={onChange} className="nodrag" />
      </div>
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

const nodeTypes = { textUpdater: TextUpdaterNode };

const nodes = [
  { id: 'node-1', type: 'textUpdater', position: { x: 0, y: 0 }, data: {} },
];

// <ReactFlow nodes={nodes} nodeTypes={nodeTypes} fitView />

Two habits will save you a lot of confusion here. First, define nodeTypes and edgeTypes outside your component, or memoize them with useMemo. A fresh object reference on every render triggers console warnings and needless re-renders. Second, add the nodrag, nowheel, and nopan class names to interactive elements inside custom nodes. Without nodrag on that input, clicking into it would start dragging the whole node instead of letting you type.

Reaching for the Imperative Handle

Most of React Flow is declarative, but sometimes you need to do something imperatively, like centering the view on a node, programmatically fitting the view, or reading the current node set outside the render flow. The useReactFlow hook hands you the instance:

import { useReactFlow } from '@xyflow/react';

function Toolbar() {
  const { fitView, setCenter, getNodes } = useReactFlow();

  const focusFirst = () => {
    const [first] = getNodes();
    if (first) {
      setCenter(first.position.x, first.position.y, { zoom: 1.5, duration: 800 });
    }
  };

  return (
    <div>
      <button onClick={() => fitView({ duration: 400 })}>Fit view</button>
      <button onClick={focusFirst}>Focus first node</button>
    </div>
  );
}

There is one rule worth remembering: hooks like useReactFlow need a React Flow context to read from. If you call them from a component that lives outside the <ReactFlow> subtree, wrap your app in <ReactFlowProvider> so the instance is available everywhere.

Adding Automatic Layout

React Flow deliberately does not arrange your nodes for you. Positions are your responsibility, which is great when you control them and a chore when you have a graph and just want it to look tidy. The standard approach is to plug in an external layout engine such as Dagre or ELK (elkjs), compute positions, and write them back into node state. Here is the shape of a Dagre integration:

import Dagre from '@dagrejs/dagre';
import { type Node, type Edge } from '@xyflow/react';

function getLayoutedElements(nodes: Node[], edges: Edge[]) {
  const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
  g.setGraph({ rankdir: 'TB' });

  edges.forEach((edge) => g.setEdge(edge.source, edge.target));
  nodes.forEach((node) =>
    g.setNode(node.id, {
      width: node.measured?.width ?? 150,
      height: node.measured?.height ?? 50,
    }),
  );

  Dagre.layout(g);

  return {
    nodes: nodes.map((node) => {
      const { x, y } = g.node(node.id);
      return { ...node, position: { x, y } };
    }),
    edges,
  };
}

Call this whenever you want to re-arrange, then feed the returned nodes into setNodes. Because layout is just a transformation of your own data, you can swap Dagre for ELK, run it on a button click, or even re-layout automatically when a node is added. The library stays out of your way and lets the layout engine of your choice do the geometry.

Where It Fits, and Where It Does Not

React Flow has become the de-facto default for node editors in the React ecosystem, and the reasons are practical: nodes are real components, the TypeScript support is strong, the docs are excellent, and the controlled model composes with everything else in your app. With roughly 37k GitHub stars and millions of weekly downloads across its packages, it is a safe, production-proven choice.

It is worth knowing the boundaries, though. If you need heavy graph-analysis algorithms like centrality or pathfinding, Cytoscape.js is built for that. For bespoke, low-level data visualization, D3 gives you maximum control at the cost of building all the interaction yourself. If you are on Svelte rather than React, reach for Svelte Flow. And if your real need is a compute-oriented dataflow engine where the graph actually runs logic, Rete.js leans harder into that niche.

But if your task is to let a user build, edit, and connect rich React-component nodes on a canvas, React Flow is hard to beat. Install @xyflow/react, remember the stylesheet and the container dimensions, keep your node and edge data in state, and you will have a working node editor faster than you expected, with all the room you need to grow it into something genuinely polished.