0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React FlowでSVGアイコン付きノードをドラッグ&ドロップで追加してみる

0
Posted at

はじめに

以前の業務で、React Flowを使ってノードベースのUIを実装したことがあります。

ノードを配置し、線でつなぎ、ユーザーが画面上で処理の流れを編集できるUIは、ワークフローや処理フローの可視化と相性がよいです。

今回は、そのときに扱っていたようなノードベースUIを、現在の @xyflow/react を使って改めて整理します。

この記事では、以下を実装します。

  • React Flowでノードとエッジを表示する
  • SVGアイコン付きのカスタムノードを作る(API・Database)
  • MailノードはSVGアイコン自体をノードとして表示する
  • サイドバーにドラッグ用のアイコンを表示する
  • サイドバーからキャンバスへドラッグ&ドロップしてノードを追加する
  • 追加したノード同士を接続する
  • Background / Controls / MiniMap を表示する

目的は、React Flowの機能をすべて網羅することではなく、業務画面でも使われるような「部品パレットからノードを配置するUI」の基本構成を確認することです。

サンプルコード

この記事で確認したサンプルコードは以下に置いています。

GitHubのソースコード

今回作るもの

左側にノードの部品パレット、右側に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 で読み込みます。

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が崩れることがあります。

ノードテンプレートを用意する

サイドバーに表示するノードの種類を定義します。

src/data/nodeTemplates.ts
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自体をノードとして表示する形にしています。

src/components/IconNode.tsx
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アイコンをカード内に埋め込む形にしています。

サイドバーを作る

次に、ドラッグ元になるサイドバーを作ります。

src/components/Sidebar.tsx
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本体を作ります。

src/components/FlowEditor.tsx
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のコンテキストが必要です。

そのため、FlowEditorReactFlowProvider を使い、内側の FlowCanvasuseReactFlow() を呼んでいます。

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,
  },
};

ノード同士を接続する

ノード同士の接続には onConnectaddEdge を使います。

const onConnect = useCallback(
  (connection: Connection) => {
    setEdges((currentEdges) => addEdge(connection, currentEdges));
  },
  [setEdges],
);

Handle 同士をドラッグして接続すると、onConnect が呼ばれます。

そこで addEdge() を使って、既存のエッジ配列に新しいエッジを追加しています。

Appから表示する

App.tsx から FlowEditor を表示します。

src/App.tsx
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 にスタイルを追加します。

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-layoutheight: 640px; を指定しています。

.flow-layout {
  height: 640px;
}

動作確認

開発サーバーを起動します。

npm run dev

ブラウザで表示します。

http://localhost:5173

以下を確認します。

  • 初期ノードが表示される
  • 初期ノード同士がエッジで接続されている
  • ノードをドラッグして移動できる
  • サイドバーの部品をキャンバスにドラッグ&ドロップできる
  • ドロップした位置にSVGアイコン付きノードが追加される
  • ノードのHandle同士をドラッグして接続できる
  • Background / Controls / MiniMap が表示される

react-leaflet-map-sample.gif

よくあるエラー

React Flowの表示が崩れる

React FlowのCSSを読み込んでいるか確認します。

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

画面にReact Flowが表示されない

React Flowを表示する親要素に高さがあるか確認します。

.flow-layout {
  height: 640px;
}

高さがない場合、キャンバスが見えないことがあります。

ドロップしてもノードが追加されない

onDragOverevent.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内の座標に変換する
  • ドロップした位置にノードを追加する
  • HandleaddEdge() でノード同士を接続する
  • Background / Controls / MiniMap を表示する

React Flowを使うと、ノードベースのUIをReactで実装しやすくなります。

特に、サイドバーから部品を配置してフローを作るUIは、ワークフロー、承認フロー、処理フロー、ノーコード風UIなどに応用しやすい構成です。

ノードベースUIでは、単にノードを表示するだけでなく、ユーザーが配置・接続・編集できることが重要な観点になります。

今回のサンプルは最小構成ですが、ノードベースUIの土台として拡張しやすい構成です。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?