💠

React Flowで叶える柔軟なフローチャートの実装方法

2024/12/19に公開

こんにちは。マネーフォワード名古屋拠点でフロントエンドエンジニアをしている@cheez921です。
最近はデザインにも少し関わるようになり、Webアクセシビリティにも興味を持っています。

マネーフォワード名古屋拠点では、マネーフォワードクラウドのワークフローの基盤システムを開発しており、今年、申請者の組織や役職による条件分岐ができるワークフロー機能をリリースしました🎉

ワークフローの条件分岐図。条件分岐1では、申請者の所属組織や役職に応じて分岐している。分岐後は、ステップ1やステップ2に進むフローが表示されている。

参考: 「ワークフロー」機能で申請者の組織や役職による条件分岐ができるようになりました | マネーフォワード クラウド人事管理サポート

この機能を開発する中で、React Flowというライブラリに大変お世話になったため、今回はこのライブラリについての記事を書こうと思います。

React Flowとは

React Flowは、グラフ理論に基づくデータ構造を活用して、フローチャートやネットワーク図などのインタラクティブな図を作成・操作できるReactライブラリです。
ノード(要素)エッジ(接続線) の状態をリストで管理し、状態を更新することでインタラクティブな操作が可能となります。

https://reactflow.dev/

React Flowの魅力

TypeScriptの型安全性

React Flowは、TypeScriptが利用される前提に設計されており、型定義がしっかりと行われています。
ライブラリ内でany型はほとんど使用されておらず、ジェネリクスを活用することでノードやエッジのデータ型を柔軟に指定できます。

https://reactflow.dev/api-reference/types

カスタマイズの自由さ

提供されたノードやエッジのスタイルを上書きするだけでなく、独自に作成したノードやエッジをフロー図のコンポーネント内で利用することができます。
具体的なコードについては、後ほど説明します。

https://reactflow.dev/examples/nodes/custom-node

https://reactflow.dev/examples/edges/custom-edges

ライブラリの更新頻度 (※ 2024年12月 現時点)

ライブラリの更新頻度が非常に高く、バグが発見されてから修正されるまでの期間が非常に短いです。
参考: https://reactflow.dev/whats-new

また、discordコミュニティもあり、サポート体制も整っています。

https://discord.gg/RVmnytFmGW

充実したドキュメント

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

2. <ReactFlow /> コンポーネントを設置する

<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>
)

設定可能なプロパティは下記に記載があります。
https://reactflow.dev/api-reference/react-flow

3. node/edgeのデータを設定する

data.ts
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によってスタイルが変わるので、こちらをご覧ください。
https://reactflow.dev/examples/edges/edge-types

const nodeTypes = {
  default: DefaultNode, // デフォルト値。上下にハンドルがあるノード
  input: InputNode, // 下にハンドルがある開始ノード
  output: OutputNode, // 上にハンドルがある終了ノード
  group: GroupNode // ノードをグルーピングするためのノード
}

const edgeTypes = {
  default: BezierEdge, 
  straight: StraightEdge, 
  step: StepEdge,
  smoothstep: SmoothStepEdge, 
  simplebezier: SimpleBezier
}

これらのデータをセットすることで、フロー図が描画されます。

App.tsx
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>
)

上から下へInput Node, Default Node, Output Nodeがつながっているフロー図

Node/Edgeをカスタムする

NodeやEdge、Handleは、それぞれカスタマイズされたコンポーネントとして定義でき、そのコンポーネントをフローに描画することが可能です。

カスタマイズされたHandleのcomponentを作成する

デフォルトのHandleでは黒い5pxの丸が表示されますが、スタイルを上書きしたカスタムコンポーネントを作成することも可能です。

ハンドルを非表示にする必要がある場合、以下の点に注意してください。

  • display: noneは使用しない
  • 幅と高さは必ず1px以上に設定する
    • React Flowが正しく動作するためには、ハンドルの寸法計算が必要なため

minWidthおよびminHeightのデフォルト値は5pxです。もし、それより小さくしたい場合は、設定を変更する必要があります。

CustomHandle.tsx
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',
      }}
    />
  );
};

https://reactflow.dev/api-reference/components/handle

カスタマイズされたNodeのcomponentを作成する

1. カスタムノードに必要なデータをセットする

カスタムノードで使用するデータは、React Flowのノードオブジェクト内のdataプロパティに格納することで、カスタムノード内でそのデータを参照して利用できるようになります。

nodes.ts
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の型)
CustomNode.tsx
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プロパティに格納することで、カスタムノード内でそのデータを参照して利用できるようになります。

edges
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 />に渡してエッジを描画します。

CustomEdge.tsx
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以外の要素(ラベルやボタンなど)を設置することができます。

CustomEdge.tsx
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>
    </>
  );
};

カスタマイズされたNode/Edgeを <ReactFlow />に設定する

<ReactFlow />コンポーネントのnodeTypes/edgeTypesにそれぞれ設定したNodeType/EdgeTypeとカスタムコンポーネントを紐づけた値を渡すことで、カスタムコンポーネントが描画されます。

App.tsx
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 />コンポーネントの内部で管理している状態にアクセスして操作が可能です。

例として、下記のようなノードを削除するようなコードを残しておきます。

App.tsx
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>
  );
}
CustomNode.tsx
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" />
    </>
  );
};

中央のノードが削除されるアニメーション

参考ドキュメント

https://reactflow.dev/api-reference/react-flow-provider

https://reactflow.dev/api-reference/hooks/use-react-flow

使ってみて苦戦した部分

概ね痒いところに手が届くライブラリでしたが、ワークフローの基盤システムを開発する上で苦戦した部分も結構ありました。
いくつかピックアップして共有いたします。

描画されたノードからレイアウトを計算する

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に基本的なレイアウト機能を組み込むことも可能ですが、アプリの要件はお客様が一番よくご存じだと思いますし、多くの選択肢がある中で、その仕事に最適なツールを選択されるのがよいと思います(もちろん、私たちの仕事が増えることは言うまでもありません)。

参考: https://reactflow.dev/learn/layouting/layouting

そのため、私はこのワークフローの基盤システムをレイアウトする際に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やコントローラーなど、紹介しきれていないくらい機能が充実しています。

ぜひ皆さんも試してみてください!

Money Forward Developers

Discussion