React Flowで叶える柔軟なフローチャートの実装方法
こんにちは。マネーフォワード名古屋拠点でフロントエンドエンジニアをしている@cheez921です。
最近はデザインにも少し関わるようになり、Webアクセシビリティにも興味を持っています。
マネーフォワード名古屋拠点では、マネーフォワードクラウドのワークフローの基盤システムを開発しており、今年、申請者の組織や役職による条件分岐ができるワークフロー機能をリリースしました🎉
参考: 「ワークフロー」機能で申請者の組織や役職による条件分岐ができるようになりました | マネーフォワード クラウド人事管理サポート
この機能を開発する中で、React Flowというライブラリに大変お世話になったため、今回はこのライブラリについての記事を書こうと思います。
React Flowとは
React Flowは、グラフ理論に基づくデータ構造を活用して、フローチャートやネットワーク図などのインタラクティブな図を作成・操作できるReactライブラリです。
ノード(要素) と エッジ(接続線) の状態をリストで管理し、状態を更新することでインタラクティブな操作が可能となります。
React Flowの魅力
TypeScriptの型安全性
React Flowは、TypeScriptが利用される前提に設計されており、型定義がしっかりと行われています。
ライブラリ内でany型はほとんど使用されておらず、ジェネリクスを活用することでノードやエッジのデータ型を柔軟に指定できます。
カスタマイズの自由さ
提供されたノードやエッジのスタイルを上書きするだけでなく、独自に作成したノードやエッジをフロー図のコンポーネント内で利用することができます。
具体的なコードについては、後ほど説明します。
ライブラリの更新頻度 (※ 2024年12月 現時点)
ライブラリの更新頻度が非常に高く、バグが発見されてから修正されるまでの期間が非常に短いです。
参考: https://reactflow.dev/whats-new
また、discordコミュニティもあり、サポート体制も整っています。
充実したドキュメント
Refarenceはもちろんのこと、Exapmlesも非常に充実しており、実現したい機能に対するコード例を簡単に見つけることができます。
また、ライブラリを選定する上で、アクセシビリティやパフォーマンス、利用している状態管理ライブラリなどをチェックしていく必要があると思います。
それらの項目に対するアンサーがドキュメントに書かれており、これらの点についてはPoCを行う必要がなく、問題がないと判断できました。
-
アクセシビリティ
- ノードとエッジをキーボードでフォーカス、選択、移動、削除できる
- お問い合わせ窓口への導線が設置されている点も良いポイント
-
テスト
- CypressやPlaywrightを用いたテストを推奨しているが、Jestでモックする際の設定方法も記載されている
-
レンダリングの負荷
- "You doubt we can render a lot of nodes and edges? See for yourself."という公式からの強めのメッセージがとても良い
-
状態管理ライブラリとの併用
- React Flow内部で使用されている状態管理ライブラリZustandをベースにした使用例が示されている
React Flowの基本的な使い方
React Flowのセッティングする
1. インストール
お使いのパッケージマネージャーで@xyflow/reactをインストールしてください。
npm install @xyflow/react
<ReactFlow />
コンポーネントを設置する
2. <ReactFlow />
コンポーネントは、ノードやエッジを描画する大元のcomponentです。
描画されるフロー図の範囲は親要素に依存するため、必ずwidth/heightが設定されている要素でラップしてください。
import { ReactFlow } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
const App = () => (
<div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow />
</div>
)
設定可能なプロパティは下記に記載があります。
3. node/edgeのデータを設定する
import { Node, Edge } from "@xyflow/react";
export const Nodes: Node[] = [
{
id: '1', // Nodeのid(重複不可)
type: 'input', // Nodeの種別
data: { label: 'Input Node' }, // Node中央に表示されるラベル
position: { x: 0, y: 50 }, // Nodeを表示する位置
},
];
export const Edges: Edge[] = [
{
id: "e1-2", // Edgeのid
source: "1", // 接続元のNodeのid
target: "2", // 接続先のNodeのid
},
];
下記nodeTypeがデフォルトで用意されており、何も指定しなかった場合はDefaultNode
が表示されます。
また、edgeはtypeによってスタイルが変わるので、こちらをご覧ください。
const nodeTypes = {
default: DefaultNode, // デフォルト値。上下にハンドルがあるノード
input: InputNode, // 下にハンドルがある開始ノード
output: OutputNode, // 上にハンドルがある終了ノード
group: GroupNode // ノードをグルーピングするためのノード
}
const edgeTypes = {
default: BezierEdge,
straight: StraightEdge,
step: StepEdge,
smoothstep: SmoothStepEdge,
simplebezier: SimpleBezier
}
これらのデータをセットすることで、フロー図が描画されます。
import { ReactFlow } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { nodes, edges } from './data.ts';
const App = () => (
<div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow
nodes={nodes}
edges={edges}
/>
</div>
)
Node/Edgeをカスタムする
NodeやEdge、Handleは、それぞれカスタマイズされたコンポーネントとして定義でき、そのコンポーネントをフローに描画することが可能です。
カスタマイズされたHandleのcomponentを作成する
デフォルトのHandleでは黒い5pxの丸が表示されますが、スタイルを上書きしたカスタムコンポーネントを作成することも可能です。
ハンドルを非表示にする必要がある場合、以下の点に注意してください。
-
display: none
は使用しない - 幅と高さは必ず1px以上に設定する
- React Flowが正しく動作するためには、ハンドルの寸法計算が必要なため
minWidth
およびminHeight
のデフォルト値は5pxです。もし、それより小さくしたい場合は、設定を変更する必要があります。
import type { FC } from 'react';
import { Handle } from '@xyflow/react';
import type { HandleProps } from '@xyflow/react';
export const CustomHandle: FC<HandleProps> = (props) => {
return (
<Handle
{...props}
style={{
width: 1,
height: 1,
minWidth: 1,
minHeight: 1,
border: 0,
visibility: "hidden",
cursor: 'default',
}}
/>
);
};
カスタマイズされたNodeのcomponentを作成する
1. カスタムノードに必要なデータをセットする
カスタムノードで使用するデータは、React Flowのノードオブジェクト内のdataプロパティに格納することで、カスタムノード内でそのデータを参照して利用できるようになります。
export type NodeData = {
title: string;
content: string;
};
export const nodes: Node<NodeData, "custom">[] = [
{
id: "1",
type: "custom", // 追加
data: {
title: "ステップ1",
content: "朝ごはんを食べる",
},
position: { x: 0, y: 0 },
},
{
id: "2",
type: "custom",
data: {
title: "ステップ2",
content: "歯を磨く",
},
position: { x: 100, y: 125 },
},
{
id: "3",
type: "custom",
data: {
title: "ステップ3",
content: "着替える",
},
position: { x: 200, y: 250 },
},
];
2. カスタムノードコンポーネントの作成
基本的には、どのようなコンポーネントでもカスタムノードとして扱うことが可能です。
例えば、フォームを含むカードやボタンなどもカスタムノードとして設定できます。
ただし、カスタムノードを正しく機能させるためには、以下の2点を考慮する必要があります。。
- エッジをノードに接続するために、ハンドルコンポーネントを実装すること
- 入力用と出力用の接続ポイントを作成するために
type="target"
とtype="source"
のハンドルをカスタムノード内に実装する必要があります
- 入力用と出力用の接続ポイントを作成するために
- コンポーネントのPropsの型定義には
NodeProps<Node<NodeData>>
を使用すること- カスタムノードは、React Flowの選択やドラッグといった基本的な機能を提供するようなコンポーネントでラップされます
- 位置やデータなどのプロパティがラッパーコンポーネントから適切に渡されるようになります。(参考: NodePropsの型)
import { NodeData } from "./nodes"
import type { NodeProps, Node } from "@xyflow/react";
import { Position } from "@xyflow/react";
import { CustomHandle } from "./CustomHandle"
type CustomNodeProps = NodeProps<Node<NodeData>>;
const CustomNode: FC<CustomNodeProps> = (props) => {
return (
<>
<CustomHandle position={Position.Top} type="target" />
<div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 8 }}>
<h3>{props.data.title}</h3>
<p>{props.data.content}</p>
</div>
<CustomHandle position={Position.Bottom} type="source" />
</>
);
};
カスタマイズされたEdgeのcomponentを作成する
1. カスタムエッジに必要なデータをセットする
カスタムノードと同様に、カスタムエッジで使用するデータは、React Flowのエッジオブジェクト内のdataプロパティに格納することで、カスタムノード内でそのデータを参照して利用できるようになります。
import { Edge } from "@xyflow/react";
export type EdgeData = {
inputLabel: string;
outputLabel: string;
};
export const initialEdges: Edge<EdgeData, "custom">[] = [
{
id: "e1-2",
source: "1",
target: "2",
type: "custom", // 追加
data: {
inputLabel: "ステップ1を出発",
outputLabel: "ステップ2に到着",
},
},
{
id: "e2-3",
source: "2",
target: "3",
type: "custom",
data: {
inputLabel: "ステップ2を出発",
outputLabel: "ステップ3に到着",
},
},
];
2. カスタムエッジコンポーネントの作成
Propsの設定方法などはカスタムノードと一緒ですが、edgeの実体はsvgのpathであり、通常は <BaseEdge />
コンポーネントを使用してレンダリングされます。
レンダリングするsvgのpathを計算するために、React FlowにはEdge Typesのスタイルを実現する便利なユーティリティ関数がいくつか用意されています。
- getBezierPath
- getSimpleBezierPath
- getSmoothStepPath
- getStraightPath
この関数を利用してpathを生成して、生成したpathを<BaseEdge />
に渡してエッジを描画します。
import { EdgeData } from "./edges"
import type { EdgeProps, Edge } from "@xyflow/react";
import {
BaseEdge,
getSmoothStepPath,
} from "@xyflow/react";
type CustomEdgeProps = EdgeProps<Edge<EdgeData>>;
const CustomEdge: FC<CustomEdgeProps> = (props) => {
const { id, sourceX, sourceY, targetX, targetY } = props;
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
});
return <BaseEdge id={id} path={edgePath} />;
};
また、<EdgeLabelRenderer />
を利用することで、エッジの上にpath以外の要素(ラベルやボタンなど)を設置することができます。
type CustomEdgeProps = EdgeProps<Edge<EdgeData>>;
const CustomEdge: FC<CustomEdgeProps> = (props) => {
const { id, sourceX, sourceY, targetX, targetY } = props;
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
});
return (
<>
<BaseEdge id={id} path={edgePath} />
<EdgeLabelRenderer>
<span
style={{
fontSize: 8,
backgroundColor: "#fff",
position: "absolute",
transform: `translate(-50%, -50%) translate(${sourceX}px,${
sourceY + 10
}px)`,
}}
>
{props.data?.inputLabel}
</span>
<span
style={{
fontSize: 8,
backgroundColor: "#fff",
position: "absolute",
transform: `translate(-50%, -50%) translate(${targetX}px,${
targetY - 10
}px)`,
}}
>
{props.data?.outputLabel}
</span>
</EdgeLabelRenderer>
</>
);
};
<ReactFlow />
に設定する
カスタマイズされたNode/Edgeを <ReactFlow />
コンポーネントのnodeTypes
/edgeTypes
にそれぞれ設定したNodeType
/EdgeType
とカスタムコンポーネントを紐づけた値を渡すことで、カスタムコンポーネントが描画されます。
import { ReactFlow } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { nodes } from './nodes';
import { edges } from './edges';
+ import { CustomNode } from './CustomNode';
+ import { CustomEdge } from './CustomEdge';
+ const nodeTypes = {
+ custom: CustomNode, // keyにNodeType名, valueにカスタムノードコンポーネント
+ };
+ const edgeTypes = {
+ custom: CustomEdge, // keyにEdgeType名, valueにカスタムエッジコンポーネント
+ };
const App = () => (
<div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow
+ nodeTypes={nodeTypes}
+ edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
/>
</div>
)
これらの実装をすることで、下記のようなフロー図が完成します。
補足: React Flowのnode/edgeの状態を変更するメソッドをカスタムノード / カスタムエッジ内で利用する
React Flow内部では、zustand
というライブラリで状態管理をしています。
React Flowでは<ReactFlowProvider />
というコンテキストプロバイダーが準備されており、
カスタムコンポーネントで<ReactFlow />
コンポーネントの内部で管理している状態にアクセスして操作が可能です。
例として、下記のようなノードを削除するようなコードを残しておきます。
import { ReactFlow } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
+ import { nodes as initialNodes } from './nodes';
+ import { edges as initialEdges } from './edges';
import { CustomNode } from './CustomNode';
import { CustomEdge } from './CustomEdge';
const App = () => {
+ const [nodes, setNodes] = useState(initialNodes);
+ const [edges, setEdges] = useState(initialEdges);
+ // 内部の状態の変更を検知して再描画する
+ const onNodesChange = useCallback(
+ (changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
+ [setNodes]
+ );
+ const onEdgesChange = useCallback(
+ (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
+ [setEdges]
+ );
const nodeTypes = {
custom: CustomNode,
};
const edgeTypes = {
custom: CustomEdge,
};
return (
+ <ReactFlowProvider>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
+ onNodesChange={onNodesChange}
+ onEdgesChange={onEdgesChange}
/>
+ </ReactFlowProvider>
);
}
import { NodeData } from "./nodes"
import type { NodeProps, Node } from "@xyflow/react";
+ import { Position, useReactFlow } from "@xyflow/react";
import { CustomHandle } from "./CustomHandle"
type CustomNodeProps = NodeProps<Node<NodeData>>;
const CustomNode: FC<CustomNodeProps> = (props) => {
+ const { getNodes, setNodes } = useReactFlow();
+ const handleDelete = () => {
+ const nodes = getNodes();
+ setNodes(nodes.filter(({ id }) => id !== props.id));
+ };
return (
<>
<CustomHandle position={Position.Top} type="target" />
<div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 8 }}>
<h3>{props.data.title}</h3>
<p>{props.data.content}</p>
+ <button onClick={handleDelete} style={{ fontSize: 10 }}>
+ ノードを削除する
+ </button>
</div>
<CustomHandle position={Position.Bottom} type="source" />
</>
);
};
参考ドキュメント
使ってみて苦戦した部分
概ね痒いところに手が届くライブラリでしたが、ワークフローの基盤システムを開発する上で苦戦した部分も結構ありました。
いくつかピックアップして共有いたします。
描画されたノードからレイアウトを計算する
React Flowでは、自動レイアウトするような仕組みは意図的にビルドインされていません。
そのため、React Flowは別のレイアウトライブラリと併用することを推奨しています。
We regularly get asked how to handle layouting in React Flow. While we could build some basic layouting into React Flow, we believe that you know your app’s requirements best and with so many options out there we think it’s better you choose the best right tool for the job (not to mention it’d be a whole bunch of work for us).
React Flowでレイアウトを処理する方法を定期的に尋ねられます。React Flowに基本的なレイアウト機能を組み込むことも可能ですが、アプリの要件はお客様が一番よくご存じだと思いますし、多くの選択肢がある中で、その仕事に最適なツールを選択されるのがよいと思います(もちろん、私たちの仕事が増えることは言うまでもありません)。
そのため、私はこのワークフローの基盤システムをレイアウトする際にdagreを利用しています。
スクロール範囲をフロー図が描画されている範囲に制御する
スクロール範囲を制御しない場合、フロー図が表示されていない範囲も含めて無限にスクロールすることが可能です。
そのため、ユーザーが意図せず描画されていない領域にスクロールされないように制御する必要がありました。
スクロール範囲をフロー図が描画されている範囲に制御する方法に関しては、特に実装例などが見つからず、可能か不可能かすらわかりませんでしたが、リファレンスを読んで2つの機能を組み合わせることで実現ができました。
-
getNodesBounds
- ノードの位置とサイズ(幅と高さ)を考慮して、現在のノード群全体を囲む境界ボックスを計算する関数
- x: ノード群の最も左端のx座標
- y: ノード群の最も上端のy座標
- width: ノード群の横幅の合計 (最大幅 - 最小幅)
- height: ノード群の縦幅の合計 (最大高 - 最小高)
- ノードの位置とサイズ(幅と高さ)を考慮して、現在のノード群全体を囲む境界ボックスを計算する関数
-
translateExtent
- ビューポート(表示領域)がどこまで移動できるかを制限するための「境界範囲」を定義する
getNodesBounds
でフロー図が描画されている範囲を特定し、必要に応じて余白などを追加してtranslateExtentに設定することで、期待するスクロール範囲を実装することができました。
カスタムエッジの任意のタイミングで曲げる
getSmoothStepPath
などのEdgeのpathを生成するユーティリティ関数を使う場合、カーブの位置などは制御できません。
そのため、getSmoothStepPath
などのユーティリティ関数を使わずに、愚直にsvgのpathを生成するビルダー関数を作成してデザイナーが意図したEdgeの曲線を描くようにしました。
(もっと良い方法があれば教えていただきたいです🙇)
const buildPath = (args: EdgePosition) => {
const { sourceX, sourceY, targetX, targetY } = args;
return `
M ${sourceX} ${sourceY}
L ${sourceX} ${targetY - Y_SPACE - RADIUS}
A ${RADIUS} ${RADIUS} 0 0 0 ${sourceX + RADIUS} ${targetY - Y_SPACE}
L ${targetX - RADIUS} ${targetY - Y_SPACE}
A ${RADIUS} ${RADIUS} 0 0 1 ${targetX} ${targetY - Y_SPACE + RADIUS}
L ${targetX} ${targetY}
`;
};
ここまでお読みいただき、ありがとうございました。
React Flowは実際に多くのプロダクトで実装されている実績あるライブラリです。
また、MiniMapやコントローラーなど、紹介しきれていないくらい機能が充実しています。
ぜひ皆さんも試してみてください!
Discussion