React でゼロからフローチャートUIを実装する
最近、AIのワークフローを簡単に組める OSS「Dify」が注目を集めています。
Difyではブラウザ上でフローチャートを構築してLLMのワークフローを設計できます。
今回はこのUIの実装を理解するためにReactでフローチャートUIを実装してみようと思います。DifyではフローチャートUIの構築に「React Flow」を使用しています。React Flow は React で使えるフローチャートUIライブラリです。本記事の実装でも React Flow を参考にしてきます。
本記事はフローチャートUIの仕組みを理解することを目的にしています。
フローチャートUIの要素
フローチャートは主に、ノードとエッジから構成されます。ノード同士はエッジで繋ぐことができます。
この記事ではエッジ接続部分をコネクターと呼びます。
つくるもの
シンプルなフローチャートUIを実装します。
今回作るフローチャートUIの仕様
- ノードをドラッグ&ドロップで移動できる
- ノードからドラックでエッジを生やすことができる
- エッジでノードを接続できる
この仕様がフローチャートUIの基本となります。意外とシンプルな仕様ですね。この基本の実装を理解できれば、あとは比較的簡単な応用で独自のフローチャートUIを実装することができると思います。
実装
それではReactでフローチャートUIを実装していきます。
各実装 step に 🧩 React Flow ではどのように実装しているか についても書いているので適宜参考にしてください。
step1 ノードを作成する
まず最初にノードを実装します。
今回作るノードの仕様
- 識別子のIDと位置を示すpositionをpropsで受け取る
- 両サイドにコネクターがある
- 中央にIDを表示する
import "./node.css";
type Position = {
x: number;
y: number;
};
type Props = {
id: string;
position: Position;
};
export const NodeComponent = ({ id, position }: Props) => {
return (
<div
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}
className="node-wrapper"
>
<div className="node-outside">
<div className="connector" />
<div className="node">{id}</div>
<div className="connector" />
</div>
</div>
);
};
node.css
.node {
/* for node outline */
border: solid 1px;
width: 100px;
height: 40px;
border-radius: 6px;
padding: 8px 16px;
/* for node content layout */
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.node-outside {
display: flex;
align-items: center;
}
.connector {
width: 10px;
height: 10px;
border: solid 1px;
border-radius: 50%;
cursor: pointer;
}
.node-wrapper {
position: absolute;
}
🧩 React Flow の実装
packages/react/src/components/Nodes
の中で色々な種類のNodeが定義されています。
NodeWrapper
の中transform: tranlate(x, y)
で座標を指定しています。
step2 ボードにノードを配置する
step1で作成したノードコンポーネントをボードに配置します。
Board.tsx でノードの配列を定義して動的にノードを配置しています。
import { NodeComponent } from "../node/NodeComponent";
import { useState } from "react";
type Node = {
id: string;
position: Position;
};
export function Board() {
const [nodes, setNodes] = useState<Node[]>([
{
id: "node1",
position: { x: 100, y: 100 },
},
{
id: "node2",
position: { x: 400, y: 100 },
},
]);
return (
<>
<div className="bord">
{nodes.map((node) => (
<NodeComponent
key={node.id}
id={node.id}
position={node.position}
/>
))}
</div>
</>
);
}
ボードは画面全体を範囲としています。
.board {
position: relative;
width: 100vw;
height: 100vh;
}
ボードコンポーネントはApp.tsxで読み込んで表示します。
import { Board } from "./board/Board";
import "./App.css";
export default function App() {
return <Board />;
}
🧩 React Flow の実装
React Flow でのボードはGraphView
コンポーネントです。ここでノード、エッジを配置しています。
step3 ノードをドラッグ&ドロップで移動できるようにする
ドラッグ開始
NodeComponent のonMouseDown
コールバックから Board にドラッグが開始されたノードのIDが渡されます。Board ではこのノードIDを見て座標を更新すべきノードを判定しています。
ドラッグ
NodeComponent には props で座標を渡しているので、この座標をマウスに追従する形で更新すればドラッグすることができます。Board のDOMにonMouseMovbe
イベントを設定して、movementX
, movementY
でマウスが移動したときに移動量を取得します。そしてマウス移動量をノードの座標に加算することで座標を更新します。
ドラッグの終了 (ドロップ)
Board のDOMにonMouseUp
イベントを設定してドラッグが終わった時にselectedNodeId
をnullに設定して、ドラッグが終わったことを表現しています。onMouseMoveBoard
の中でselectedNodeId
を見て null の場合は座標の更新をしません。
selectedNodeId を useState ではなく useRef で管理する理由
selectedNodeId
の変更は画面の更新を必要としないので、useState
ではなく、useRef
で値を管理しています。useRef
を使うことで selectedNodeId
が更新された時の不要な再レンダリングを防止することができます。
また、useState
は setState
後の再レンダリングの完了後に値が更新されるので、イベントハンドラ内で参照すると更新前の値を参照してしまうことがあります。useRef
はコンポーネントのレンダリングサイクルと結びついていないので、参照時に常に最新の値を参照することができます。
useRef
を値の参照に使う場合の注意点などは公式ドキュメントに分かりやすい解説が書かれています。
import { NodeComponent } from "../node/NodeComponent";
import { useRef, useState } from "react";
import "./board.css";
type Node = {
id: string;
position: Position;
};
export function Board() {
const [nodes, setNodes] = useState<Node[]>([
{
id: "node1",
position: { x: 100, y: 100 },
},
{
id: "node2",
position: { x: 400, y: 100 },
},
]);
+ const selectedNodeId = useRef<string | null>(null);
+ const onMouseDownNode = (id: string) => {
+ selectedNodeId.current = id;
+ };
+ const onMouseUpBoard = () => {
+ selectedNodeId.current = null;
+ };
+ // ボード上でマウスが移動するたびに呼ばれる
+ const onMouseMoveBoard = (
+ e: React.MouseEvent<HTMLDivElement, MouseEvent>
+ ) => {
+ if (selectedNodeId.current) {
+ const node = nodes.find((node) => node.id === selectedNodeId.current);
+ if (!node) return;
+
+ // ドラッグ中のノードの位置を更新
+ setNodes(
+ nodes.map((node) =>
+ node.id === selectedNodeId.current
+ ? {
+ ...node,
+ position: {
+ x: node.position.x + e.movementX,
+ y: node.position.y + e.movementY,
+ },
+ }
+ : node
+ )
+ );
+ }
+ };
return (
<>
<div
+ className="board"
+ onMouseMove={onMouseMoveBoard}
+ onMouseUp={onMouseUpBoard}
+ onMouseLeave={onMouseUpBoard}
>
{nodes.map((node) => (
<NodeComponent
key={node.id}
id={node.id}
position={node.position}
+ onMouseDownNode={onMouseDownNode}
/>
))}
</div>
</>
);
}
import "./node.css";
type Props = {
id: string;
position: Position;
+ onMouseDownNode: (id: string) => void;
};
export const NodeComponent = ({
id,
position,
+ onMouseDownNode
}: Props) => {
const handleMouseDownNode = () => {
onMouseDownNode(id);
};
return (
<div
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}
+ onMouseDown={handleMouseDownNode}
className="node-wrapper"
>
<div className="node-outside">
<div
className="connector"
/>
<div className="node">{id}</div>
<div
className="connector"
/>
</div>
</div>
);
};
🧩 React Flow の実装
useMoveSelectedNodes
の中でドラッグされた時の座標更新を行っています。
step4 エッジを実装する
ノードを結ぶエッジを実装します。
座標を props で受け取るところはノードと同じですが、以下の2点が大きく違います。
- 始点と終点を座標で指定する
- <svg> を使用する
エッジは始点と終点が可変なのでそれぞれの座標を props で受け取ります。また、始点と終点を結ぶ線を引くために <svg> を使用します。
import "./edge.css";
type Props = {
from: Position;
to: Position;
};
export const EdgeComponent = ({ from, to }: Props) => {
return (
<div className="edge-wrapper">
<svg className="svg">
<path
stroke="black"
strokeWidth="2"
fill="none"
d={`M ${from.x}, ${from.y} L ${to.x}, ${to.y}`}
/>
</svg>
</div>
);
};
edge.css
.edge-wrapper {
position: absolute;
pointer-events: none;
}
.svg {
width: 100vw;
height: 100vh;
}
🧩 React Flow の実装
packages/react/src/components/Edges
の中で色々な種類のEdgeが定義されています。
step5 ノードからエッジを伸ばす
ドラッグの開始
NodeComponent のコネクターのonMouseDown
コールバックから Board にドラッグが開始されたノードのIDとコネクターの絶対位置が渡されます。Board ではコネクターの位置をエッジの始点と終点にセットします。
ドラッグ
ノードと同じく、エッジも props で座標を渡しているので、この座標をマウスに追従する形で更新すればドラッグすることができます。エッジの場合は始点はコネクター固定で終点をマウスに合わせて動かすので終点に対してmovementX
, movementY
を加算します。
import "./node.css";
type Props = {
id: string;
position: Position;
onMouseDownNode: (id: string) => void;
+ onMouseDownConnector: (connectorPosition: Position, nodeId: string) => void;
};
export const NodeComponent = ({
id,
position,
onMouseDownNode,
+ onMouseDownConnector,
}: Props) => {
const handleMouseDownNode = () => {
onMouseDownNode(id);
};
+ const handleMouseDownConnector = (e: React.MouseEvent<HTMLDivElement>) => {
+ // ノードに mouseDown イベントが伝搬してノードがドラッグされるのを防ぐ
+ e.stopPropagation();
+ const connectorRect = e.currentTarget.getBoundingClientRect();
+
+ // コネクターの中心の座標を渡す
+ onMouseDownConnector(
+ {
+ x: connectorRect.x + connectorRect.width / 2,
+ y: connectorRect.y + connectorRect.height / 2,
+ },
+ id
+ );
+ };
return (
<div
style={{
transform: `translate(${position.x}px, ${position.y}px)`,
}}
onMouseDown={handleMouseDownNode}
className="node-wrapper"
>
<div className="node-outside">
- <div className="connector" />
+ <div className="connector" onMouseDown={handleMouseDownConnector} />
<div className="node">{id}</div>
- <div className="connector" />
+ <div className="connector" onMouseDown={handleMouseDownConnector} />
</div>
</div>
);
};
import { NodeComponent } from "../node/NodeComponent";
+import { EdgeComponent } from "../edge/EdgeComponent";
import { useRef, useState } from "react";
import "./board.css";
type Node = {
id: string;
position: Position;
};
+type Edge = {
+ id: string;
+ start: Position;
+ end: Position;
+ startNodeId?: string;
+ endNodeId?: string;
+};
export function Board() {
const [nodes, setNodes] = useState<Node[]>([
{
id: "node1",
position: { x: 100, y: 100 },
},
{
id: "node2",
position: { x: 400, y: 100 },
},
]);
+ const [newEdge, setNewEdge] = useState<Edge | null>(null);
const selectedNodeId = useRef<string | null>(null);
+ const selectedEdgeId = useRef<string | null>(null);
const onMouseDownNode = (id: string) => {
selectedNodeId.current = id;
};
const onMouseUpBoard = () => {
selectedNodeId.current = null;
};
+ const onMouseDownConnector = (
+ connectorPosition: Position,
+ nodeId: string
+ ) => {
+ const edgeId = `edge-${new Date().getTime()}`;
+ selectedEdgeId.current = edgeId;
+ // ドラッグが開始された時に newEdge をセットする
+ setNewEdge({
+ id: edgeId,
+ start: {
+ x: connectorPosition.x,
+ y: connectorPosition.y,
+ },
+ end: {
+ x: connectorPosition.x,
+ y: connectorPosition.y,
+ },
+ startNodeId: nodeId,
+ endNodeId: undefined,
+ });
+ };
const onMouseMoveBoard = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
...
+ if (selectedEdgeId.current && newEdge) {
+ // エッジのドラッグ中なら終点をマウス座標で更新する
+ setNewEdge({
+ ...newEdge,
+ end: {
+ x: e.clientX,
+ y: e.clientY,
+ },
+ });
+ }
};
return (
<>
<div
className="board"
onMouseMove={onMouseMoveBoard}
onMouseUp={onMouseUpBoard}
onMouseLeave={onMouseUpBoard}
>
{nodes.map((node) => (
<NodeComponent
key={node.id}
id={node.id}
position={node.position}
onMouseDownNode={onMouseDownNode}
+ onMouseDownConnector={onMouseDownConnector}
/>
))}
+ {selectedEdgeId.current && newEdge && (
+ <EdgeComponent from={newEdge.start} to={newEdge.end} />
+ )}
</div>
</>
);
}
🧩 React Flow の実装
React Flow ではドラッグ中のエッジはConnectionLineWrapper
コンポーネントとして表現されています。
step6 エッジをノードに繋げる
エッジをドラッグしてコネクターに終点が重なったときに接続する処理を実装していきます。
エッジをコネクターに接続する
エッジをドラッグして、コネクターの上まできた時、コネクターのonMouseEnter
コールバックから Board にノードのIDとコネクターの絶対位置が渡されます。Board ではコネクターの位置をエッジの終点にセットします。
エッジが接続された時に始めて、正式にエッジが作成されたことになるのでsetEdges
でエッジを追加しています。このとき、ドラッグ表示用の一時的なエッジを消すため setNewEdge(null);
を実行しています。
ノードを動かした時にエッジが追従するようにする
ノードが動かされたとき、ノードに接続されているエッジの端点を更新してノードに追従させる必要があります。この処理はノードの座標更新と同じで、bord要素に設定されたonMouseMove
イベントの中で行います。すべてのエッジの中から、ドラッグしているノードに接続しているエッジを見つけて、始点、終点を更新します。
type Props = {
position: Position;
onMouseDownNode: (id: string) => void;
onMouseDownConnector: (connectorPosition: Position, nodeId: string) => void;
+ onMouseEnterConnector: (connectorPosition: Position, nodeId: string) => void;
};
export const NodeComponent = ({
export const NodeComponent = ({
position,
onMouseDownNode,
onMouseDownConnector,
+ onMouseEnterConnector,
}: Props) => {
const handleMouseDownNode = () => {
onMouseDownNode(id);
export const NodeComponent = ({
);
};
+ const handleMouseEnterConnector = (
+ e: React.MouseEvent<HTMLDivElement, MouseEvent>
+ ) => {
+ const connectorRect = e.currentTarget.getBoundingClientRect();
+ // マウスがコネクター上にきたときにコネクターの絶対位置を渡す
+ onMouseEnterConnector(
+ {
+ x: connectorRect.x + connectorRect.width / 2,
+ y: connectorRect.y + connectorRect.height / 2,
+ },
+ id
+ );
+ };
+
return (
<div
style={{
export const NodeComponent = ({
className="node-wrapper"
>
<div className="node-outside">
- <div className="connector" onMouseDown={handleMouseDownConnector} />
+ <div
+ className="connector"
+ onMouseDown={handleMouseDownConnector}
+ onMouseEnter={handleMouseEnterConnector}
+ />
<div className="node">{id}</div>
- <div className="connector" onMouseDown={handleMouseDownConnector} />
+ <div
+ className="connector"
+ onMouseDown={handleMouseDownConnector}
+ onMouseEnter={handleMouseEnterConnector}
+ />
</div>
</div>
);
export function Board() {
const [nodes, setNodes] = useState<Node[]>([
{
id: "node1",
position: { x: 100, y: 100 },
},
{
id: "node2",
position: { x: 400, y: 100 },
},
]);
+ const [edges, setEdges] = useState<Edge[]>([]);
const [newEdge, setNewEdge] = useState<Edge | null>(null);
const selectedNodeId = useRef<string | null>(null);
const selectedEdgeId = useRef<string | null>(null);
const onMouseDownNode = (id: string) => {
selectedNodeId.current = id;
};
const onMouseUpBoard = () => {
selectedNodeId.current = null;
+ selectedEdgeId.current = null;
+ setNewEdge(null);
};
const onMouseDownConnector = (
connectorPosition: Position,
nodeId: string
) => {
...
};
+ const onMouseEnterConnector = (
+ connectorPosition: Position,
+ nodeId: string
+ ) => {
+ if (!newEdge) return;
+ // エッジをドラッグ中にコネクター上にマウスがきたときにエッジを追加する
+ setEdges([
+ ...edges,
+ {
+ ...newEdge,
+ end: {
+ x: connectorPosition.x,
+ y: connectorPosition.y,
+ },
+ endNodeId: nodeId,
+ },
+ ]);
+ setNewEdge(null);
+ selectedEdgeId.current = null;
+ };
const onMouseMoveBoard = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) => {
if (selectedNodeId.current) {
const node = nodes.find((node) => node.id === selectedNodeId.current);
if (!node) return;
// ドラッグ中のノードの位置を更新
setNodes(
nodes.map((node) =>
node.id === selectedNodeId.current
? {
...node,
position: {
x: node.position.x + e.movementX,
y: node.position.y + e.movementY,
},
}
: node
)
);
+ // ノードのドラッグに合わせてエッジの位置を更新する
+ setEdges(
+ edges.map((edge) =>
+ edge.startNodeId === selectedNodeId.current
+ ? {
+ ...edge,
+ start: {
+ x: edge.start.x + e.movementX,
+ y: edge.start.y + e.movementY,
+ },
+ }
+ : edge.endNodeId === selectedNodeId.current
+ ? {
+ ...edge,
+ end: {
+ x: edge.end.x + e.movementX,
+ y: edge.end.y + e.movementY,
+ },
+ }
+ : edge
+ )
+ );
}
if (selectedEdgeId.current && newEdge) {
setNewEdge({
...newEdge,
end: {
x: e.clientX,
y: e.clientY,
},
});
}
};
return (
<>
<div
className="board"
onMouseMove={onMouseMoveBoard}
onMouseUp={onMouseUpBoard}
onMouseLeave={onMouseUpBoard}
>
{nodes.map((node) => (
<NodeComponent
key={node.id}
id={node.id}
position={node.position}
onMouseDownNode={onMouseDownNode}
onMouseDownConnector={onMouseDownConnector}
+ onMouseEnterConnector={onMouseEnterConnector}
/>
))}
+ {edges.map((edge) => (
+ <EdgeComponent key={`${edge.id}`} from={edge.start} to={edge.end} />
+ ))}
{selectedEdgeId.current && newEdge && (
<EdgeComponent from={newEdge.start} to={newEdge.end} />
)}
</div>
</>
);
}
🧩 React Flow の実装
HandleComponent
でエッジが接続されたときに新規エッジを追加する処理が書かれています。
エッジの始点、終点はsourceX
, sourceY
, targetX
, targetY
という変数名でエッジに渡されます。この値はノードの中にあるHandleComponent
を参照しており、ノードの位置が変わるとリアクティブに更新され、エッジがノードに追従するようになっています。
step7 エッジのカスタマイズ
直線だったエッジをカスタマイズします。エッジはsvgのパスで書かれているので自由にカスタマイズすることが可能です。ここでは3次ベジェ曲線を使ってなめらかな曲線で結ぶように変更します。
import "./edge.css";
type Props = {
from: Position;
to: Position;
};
export const EdgeComponent = ({ from, to }: Props) => {
+ const c1 = {
+ x: from.x + Math.abs(to.x - from.x),
+ y: from.y,
+ };
+ const c2 = {
+ x: to.x - Math.abs(to.x - from.x),
+ y: to.y,
+ };
return (
<div className="edge-wrapper">
<svg className="svg">
<path
stroke="black"
strokeWidth="2"
fill="none"
- d={`M ${from.x}, ${from.y} L ${to.x}, ${to.y}`}
+ d={`M ${from.x} ${from.y} C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${to.x} ${to.y}`}
/>
</svg>
</div>
);
};
制御点を可視化してみると、なんとなくベジェ曲線における制御点と曲線の関係が分かると思います。
🧩 React Flow の実装
3次ベジェ曲線のパスを構築する関数がpackages/system/src/utils/edges/
にあります。
パフォーマンスの改善
今回の実装だと Board ですべてのノード、エッジの state を管理しているため、1つのノードの state を更新するとBoard内のすべてのノード、エッジが再レンダーされてしまします。これは特にドラッグのときに問題となります。ドラッグ中は1秒間に何回も位置が更新されるので、ノード、エッジが多くあり、かつその中で重い計算を行っているとパフォーマンスが大きく低下します。
そこで
- ノード、エッジをメモ化する
- ノード座標の state 管理をボードで行わず、各ノードで行う
などの対応を行うことでパフォーマンス低下を防ぐことができます。
🧩 React Flow の実装
ノード、エッジをメモ化をした上で、ノードのドラッグによる再レンダリングの影響がそのドラッグしているノードだけになるように state 設計をしています。
NodeRendererComponent
が全ノードを管理しているコンポーネントです。このコンポーネントは全ノードのIDのリストに変更があった時のみ再実行され、単一ノードの位置更新では再実行されないようになっています。つまり、ノードのドラッグでは再実行されず、ノードの追加、削除などの時のみ再実行されます。ノードの追加、削除はドラッグの時のように state が何度も更新されることはないのでパフォーマンスへの影響は少ないです。
まとめ
以下、フローチャートUI実装の要点です。
- ノード、エッジは座標をマウス位置で更新することでドラッグ&ドロップを実装する
- エッジはsvgで実装して始点、終点を座標で指定する
- エッジにはドラッグ中の表示用のエッジと接続後に存在が確定した2つ状態がある
- ドラッグの時は再レンダリングが多くなりがちなのでパフォーマンスの工夫が必要
フローチャートUIは一見かなり複雑ですが、コア部分の実装は意外とシンプルでした。また、React Flowの機能の豊富さに驚きました。フローチャートUIを組み込みたいときは React Flow を今後も使っていこうと思います。
この記事で実装したコードはGitHubで公開しています。
Discussion
とても面白かったです!参考にさせていただきます。