はじめに
以前の業務で、React Flowを使ってノードベースのUIを実装したことがあります。
ノードを配置し、線でつなぎ、ユーザーが画面上で処理の流れを編集できるUIは、ワークフローや処理フローの可視化と相性がよいです。
今回は、そのときに扱っていたようなノードベースUIを、現在の @xyflow/react を使って改めて整理します。
この記事では、以下を実装します。
- React Flowでノードとエッジを表示する
- SVGアイコン付きのカスタムノードを作る(API・Database)
- MailノードはSVGアイコン自体をノードとして表示する
- サイドバーにドラッグ用のアイコンを表示する
- サイドバーからキャンバスへドラッグ&ドロップしてノードを追加する
- 追加したノード同士を接続する
- Background / Controls / MiniMap を表示する
目的は、React Flowの機能をすべて網羅することではなく、業務画面でも使われるような「部品パレットからノードを配置するUI」の基本構成を確認することです。
サンプルコード
この記事で確認したサンプルコードは以下に置いています。
今回作るもの
左側にノードの部品パレット、右側にReact Flowのキャンバスを用意します。
┌──────────────┬─────────────────────────┐
│ Sidebar │ React Flow Canvas │
│ │ │
│ │ [API] │
│ API │ │ │
│ DB │ │ │
│ Mail │ [DB] │
└──────────────┴─────────────────────────┘
サイドバーのSVGアイコンをドラッグし、React Flowのキャンバス上にドロップすると、その位置にカスタムノードを追加します。
前提
この記事では、Vite + React + TypeScript のプロジェクトを前提にします。
まだプロジェクトを作成していない場合は、以下で作成できます。
npm create vite@latest react-flow-drag-drop-node-sample -- --template react-ts
作成後、ディレクトリに移動します。
cd react-flow-drag-drop-node-sample
依存関係をインストールします。
npm install
検証環境
この記事は、以下の環境で動作確認しています。
OS: Windows 11
Node.js: 24.13.0
npm: 11.6.2
Vite: 8.1.1
React: 19.2.7
TypeScript: 6.0.2
@xyflow/react: 12.11.2
Browser: Google Chrome
バージョンが大きく異なる場合は、公式ドキュメントも確認してください。
React Flowについて
React Flowは、ReactでノードベースのUIを作るためのライブラリです。
以前は reactflow パッケージを使う記事も多くありましたが、現在は @xyflow/react を使います。
この記事では、以下のようにインポートします。
import { ReactFlow } from '@xyflow/react';
React Flowでは、ノード、エッジ、ハンドル、背景、ミニマップ、操作ボタンなどを組み合わせて、フロー図やノードエディタを作れます。
@xyflow/reactをインストールする
React Flowをインストールします。
npm install @xyflow/react
ファイル構成
今回の主なファイル構成は以下です。
src/
├─ App.tsx
├─ main.tsx
├─ index.css
├─ components/
│ ├─ FlowEditor.tsx
│ ├─ Sidebar.tsx
│ └─ IconNode.tsx
└─ data/
└─ nodeTemplates.ts
React FlowのCSSを読み込む
React Flowの表示にはCSSの読み込みが必要です。
src/main.tsx で読み込みます。
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@xyflow/react/dist/style.css';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@xyflow/react/dist/style.css を読み込まないと、React Flowの表示や操作UIが崩れることがあります。
ノードテンプレートを用意する
サイドバーに表示するノードの種類を定義します。
export type NodeIconType = 'api' | 'database' | 'mail';
export type NodeTemplate = {
type: NodeIconType;
label: string;
description: string;
};
export const nodeTemplates: NodeTemplate[] = [
{
type: 'api',
label: 'API',
description: '外部APIやバックエンド処理を表すノード',
},
{
type: 'database',
label: 'Database',
description: 'データ保存や参照を表すノード',
},
{
type: 'mail',
label: 'Mail',
description: 'メール送信などの通知処理を表すノード',
},
];
ノードにReact要素そのものを渡すのではなく、type という文字列を持たせています。
こうしておくと、保存や復元を考えるときにも扱いやすくなります。
SVGアイコン付きカスタムノードを作る
今回は外部アイコンライブラリを使わず、簡単なSVGをコンポーネント内に用意します。
API・DatabaseノードはSVGアイコンをカード内に配置する形にし、MailノードはSVG自体をノードとして表示する形にしています。
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { NodeIconType } from '../data/nodeTemplates';
type IconNodeData = {
label: string;
description: string;
iconType: NodeIconType;
};
function NodeIcon({ type }: { type: NodeIconType }) {
if (type === 'database') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<ellipse cx="12" cy="5" rx="7" ry="3" />
<path d="M5 5v10c0 1.7 3.1 3 7 3s7-1.3 7-3V5" />
<path d="M5 10c0 1.7 3.1 3 7 3s7-1.3 7-3" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="5" width="16" height="14" rx="2" />
<path d="M8 9h8" />
<path d="M8 13h5" />
<path d="M16 17h2" />
</svg>
);
}
export function IconNode({ data }: NodeProps<IconNodeData>) {
// MailノードはSVGアイコン自体をノードとして表示する
if (data.iconType === 'mail') {
return (
<div className="icon-node--svg">
<Handle type="target" position={Position.Top} />
<svg
width="60"
height="60"
viewBox="0 0 24 24"
aria-label={data.label}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="4" y="6" width="16" height="12" rx="2" />
<path d="m5 8 7 5 7-5" />
</svg>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
// API・DatabaseノードはSVGアイコン付きカードUIとして表示する
return (
<div className="icon-node">
<Handle type="target" position={Position.Top} />
<div className="icon-node__icon">
<NodeIcon type={data.iconType} />
</div>
<div>
<div className="icon-node__label">{data.label}</div>
<div className="icon-node__description">{data.description}</div>
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
iconType === 'mail' の場合だけ別のJSXを返しています。
MailノードはSVGのみを表示し、ラベルやdescriptionのテキストは持ちません。Handle(接続ポイント)は他のノードと同様に上下に配置しています。
API・Databaseノードは引き続きカード形式のUIで、SVGアイコンをカード内に埋め込む形にしています。
サイドバーを作る
次に、ドラッグ元になるサイドバーを作ります。
import { nodeTemplates, type NodeTemplate } from '../data/nodeTemplates';
type SidebarProps = {
onDragStart: (
event: React.DragEvent<HTMLDivElement>,
template: NodeTemplate,
) => void;
};
export function Sidebar({ onDragStart }: SidebarProps) {
return (
<aside className="sidebar">
<h2>Nodes</h2>
<p>ドラッグしてキャンバスに追加します。</p>
<div className="node-template-list">
{nodeTemplates.map((template) => (
<div
key={template.type}
className="node-template"
draggable
onDragStart={(event) => onDragStart(event, template)}
>
<div className="node-template__title">{template.label}</div>
<div className="node-template__description">
{template.description}
</div>
</div>
))}
</div>
</aside>
);
}
HTMLのDrag and Drop APIを使うため、draggable を指定しています。
<div draggable onDragStart={(event) => onDragStart(event, template)}>
ドラッグ開始時に、どの種類のノードを追加するかを dataTransfer に入れます。
React Flow本体を作る
React Flow本体を作ります。
import { useCallback } from 'react';
import {
addEdge,
Background,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
type Connection,
type Edge,
type Node,
type NodeTypes,
} from '@xyflow/react';
import { IconNode } from './IconNode';
import { Sidebar } from './Sidebar';
import type { NodeTemplate } from '../data/nodeTemplates';
const nodeTypes: NodeTypes = {
iconNode: IconNode,
};
const initialNodes: Node[] = [
{
id: 'initial-api',
type: 'iconNode',
position: { x: 0, y: 0 },
data: {
label: 'API',
description: '初期表示のAPIノード',
iconType: 'api',
},
},
{
id: 'initial-db',
type: 'iconNode',
position: { x: 0, y: 300 },
data: {
label: 'Database',
description: '初期表示のDBノード',
iconType: 'database',
},
},
];
const initialEdges: Edge[] = [
{
id: 'initial-api-db',
source: 'initial-api',
target: 'initial-db',
},
];
function FlowCanvas() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { screenToFlowPosition } = useReactFlow();
const onConnect = useCallback(
(connection: Connection) => {
setEdges((currentEdges) => addEdge(connection, currentEdges));
},
[setEdges],
);
const handleDragStart = (
event: React.DragEvent<HTMLDivElement>,
template: NodeTemplate,
) => {
event.dataTransfer.setData('application/reactflow', JSON.stringify(template));
event.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const rawTemplate = event.dataTransfer.getData('application/reactflow');
if (!rawTemplate) {
return;
}
const template = JSON.parse(rawTemplate) as NodeTemplate;
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const newNode: Node = {
id: `${template.type}-${Date.now()}`,
type: 'iconNode',
position,
data: {
label: template.label,
description: template.description,
iconType: template.type,
},
};
setNodes((currentNodes) => [...currentNodes, newNode]);
};
return (
<div className="flow-layout">
<Sidebar onDragStart={handleDragStart} />
<div className="flow-wrapper" onDragOver={handleDragOver} onDrop={handleDrop}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
</div>
);
}
export function FlowEditor() {
return (
<ReactFlowProvider>
<FlowCanvas />
</ReactFlowProvider>
);
}
ReactFlowProviderについて
useReactFlow() を使うには、React Flowのコンテキストが必要です。
そのため、FlowEditor で ReactFlowProvider を使い、内側の FlowCanvas で useReactFlow() を呼んでいます。
export function FlowEditor() {
return (
<ReactFlowProvider>
<FlowCanvas />
</ReactFlowProvider>
);
}
この構成にしておくと、screenToFlowPosition() を使って、画面上の座標をReact Flow内の座標に変換できます。
ドロップ位置にノードを追加する
ドラッグ&ドロップで重要なのは、ブラウザ上の座標をReact Flowのキャンバス座標に変換することです。
ドロップイベントで取得できる clientX / clientY は、ブラウザ画面上の座標です。
event.clientX
event.clientY
一方で、React Flowのノード位置には、React Flow内の座標が必要です。
そのため、screenToFlowPosition() で変換します。
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
変換した位置を newNode.position に設定すると、ドロップした場所に近い位置へノードを追加できます。
const newNode: Node = {
id: `${template.type}-${Date.now()}`,
type: 'iconNode',
position,
data: {
label: template.label,
description: template.description,
iconType: template.type,
},
};
ノード同士を接続する
ノード同士の接続には onConnect と addEdge を使います。
const onConnect = useCallback(
(connection: Connection) => {
setEdges((currentEdges) => addEdge(connection, currentEdges));
},
[setEdges],
);
Handle 同士をドラッグして接続すると、onConnect が呼ばれます。
そこで addEdge() を使って、既存のエッジ配列に新しいエッジを追加しています。
Appから表示する
App.tsx から FlowEditor を表示します。
import { FlowEditor } from './components/FlowEditor';
export default function App() {
return (
<main className="app">
<header className="app-header">
<h1>React Flow Drag & Drop Node Sample</h1>
<p>
SVGアイコン付きノードをサイドバーからドラッグ&ドロップで追加するサンプルです。
</p>
</header>
<FlowEditor />
</main>
);
}
CSSを追加する
src/index.css にスタイルを追加します。
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background: #f8fafc;
color: #0f172a;
}
.app {
min-height: 100vh;
padding: 24px;
}
.app-header {
max-width: 1120px;
margin: 0 auto 16px;
}
.app-header h1 {
margin-bottom: 8px;
font-size: 28px;
}
.app-header p {
margin: 0;
color: #475569;
}
.flow-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
max-width: 1120px;
height: 640px;
margin: 0 auto;
}
.sidebar {
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #fff;
padding: 16px;
}
.sidebar h2 {
margin: 0 0 8px;
}
.sidebar p {
margin: 0 0 16px;
color: #64748b;
font-size: 14px;
}
.node-template-list {
display: grid;
gap: 12px;
}
.node-template {
cursor: grab;
border: 1px solid #cbd5e1;
border-radius: 10px;
background: #f8fafc;
padding: 12px;
}
.node-template:active {
cursor: grabbing;
}
.node-template__title {
font-weight: 700;
}
.node-template__description {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.flow-wrapper {
overflow: hidden;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #fff;
}
.icon-node {
display: grid;
grid-template-columns: 40px 1fr;
gap: 10px;
min-width: 180px;
border: 1px solid #94a3b8;
border-radius: 12px;
background: #fff;
padding: 12px;
box-shadow: 0 8px 20px rgb(15 23 42 / 12%);
}
.icon-node__icon {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 10px;
background: #e0f2fe;
color: #0369a1;
}
.icon-node__icon svg {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon-node__label {
font-weight: 700;
}
.icon-node__description {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
/* MailノードはSVGアイコン自体がノードになる形 */
/* 背景・ボーダーなしで、SVGのみを表示する */
.icon-node--svg {
display: grid;
place-items: center;
color: #0369a1;
cursor: default;
}
.icon-node--svg:hover {
opacity: 0.8;
}
.icon-node--svg svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
display: block;
}
React Flowを表示する領域には高さが必要です。
このサンプルでは、.flow-layout に height: 640px; を指定しています。
.flow-layout {
height: 640px;
}
動作確認
開発サーバーを起動します。
npm run dev
ブラウザで表示します。
http://localhost:5173
以下を確認します。
- 初期ノードが表示される
- 初期ノード同士がエッジで接続されている
- ノードをドラッグして移動できる
- サイドバーの部品をキャンバスにドラッグ&ドロップできる
- ドロップした位置にSVGアイコン付きノードが追加される
- ノードのHandle同士をドラッグして接続できる
- Background / Controls / MiniMap が表示される
よくあるエラー
React Flowの表示が崩れる
React FlowのCSSを読み込んでいるか確認します。
import '@xyflow/react/dist/style.css';
画面にReact Flowが表示されない
React Flowを表示する親要素に高さがあるか確認します。
.flow-layout {
height: 640px;
}
高さがない場合、キャンバスが見えないことがあります。
ドロップしてもノードが追加されない
onDragOver で event.preventDefault() を呼んでいるか確認します。
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
これがないと、ブラウザがドロップを許可せず、onDrop が期待通りに動かない場合があります。
ノードの追加位置がずれる
event.clientX / event.clientY をそのまま使うのではなく、screenToFlowPosition() でReact Flow内の座標に変換します。
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
実務で追加で考えたいこと
今回のサンプルは、ドラッグ&ドロップでノードを追加する最小構成です。
実務で使う場合は、さらに以下のような観点を考える必要があります。
- ノードの保存・復元
- ノード削除
- ノード名や設定値の編集
- Undo / Redo
- 自動レイアウト
- 接続可能なノード種別の制御
- 不正な接続のバリデーション
- フロー全体の検証
- APIとの連携
- 権限による編集可否
- 大量ノード時の表示性能
- スマートフォンやタブレットでの操作性
業務画面では、ノードを置けるだけでなく、保存したフローを再表示したり、接続ルールを制御したりする必要が出てきます。
まずは今回のような最小構成で、ノード追加と接続の流れを押さえておくと、その後の拡張もしやすくなります。
まとめ
今回は、React FlowでSVGアイコン付きノードをドラッグ&ドロップで追加するUIを作りました。
確認したことは以下です。
-
@xyflow/reactをインストールする - React FlowのCSSを読み込む
- 初期ノードとエッジを表示する
- SVGアイコン付きのカスタムノードを作る(API・Database)
- MailノードはSVGアイコン自体をノードとして表示する
- サイドバーからドラッグできるノードテンプレートを表示する
- HTML Drag and Drop APIでドラッグ情報を渡す
-
screenToFlowPosition()で画面座標をReact Flow内の座標に変換する - ドロップした位置にノードを追加する
-
HandleとaddEdge()でノード同士を接続する - Background / Controls / MiniMap を表示する
React Flowを使うと、ノードベースのUIをReactで実装しやすくなります。
特に、サイドバーから部品を配置してフローを作るUIは、ワークフロー、承認フロー、処理フロー、ノーコード風UIなどに応用しやすい構成です。
ノードベースUIでは、単にノードを表示するだけでなく、ユーザーが配置・接続・編集できることが重要な観点になります。
今回のサンプルは最小構成ですが、ノードベースUIの土台として拡張しやすい構成です。
