🧩

React でゼロからフローチャートUIを実装する

2024/05/28に公開
1

最近、AIのワークフローを簡単に組める OSS「Dify」が注目を集めています。
https://dify.ai/jp

Difyではブラウザ上でフローチャートを構築してLLMのワークフローを設計できます。

今回はこのUIの実装を理解するためにReactでフローチャートUIを実装してみようと思います。DifyではフローチャートUIの構築に「React Flow」を使用しています。React Flow は React で使えるフローチャートUIライブラリです。本記事の実装でも React Flow を参考にしてきます。

https://reactflow.dev

本記事はフローチャートUIの仕組みを理解することを目的にしています。

フローチャートUIの要素

フローチャートは主に、ノードとエッジから構成されます。ノード同士はエッジで繋ぐことができます。
この記事ではエッジ接続部分をコネクターと呼びます。

つくるもの


シンプルなフローチャートUIを実装します。

今回作るフローチャートUIの仕様

  • ノードをドラッグ&ドロップで移動できる
  • ノードからドラックでエッジを生やすことができる
  • エッジでノードを接続できる

この仕様がフローチャートUIの基本となります。意外とシンプルな仕様ですね。この基本の実装を理解できれば、あとは比較的簡単な応用で独自のフローチャートUIを実装することができると思います。

実装

それではReactでフローチャートUIを実装していきます。
各実装 step に 🧩 React Flow ではどのように実装しているか についても書いているので適宜参考にしてください。

step1 ノードを作成する

まず最初にノードを実装します。

今回作るノードの仕様

  • 識別子のIDと位置を示すpositionをpropsで受け取る
  • 両サイドにコネクターがある
  • 中央にIDを表示する
NodeComponent.ts
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.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が定義されています。
https://github.com/xyflow/xyflow/tree/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/components/Nodes

NodeWrapperの中transform: tranlate(x, y)で座標を指定しています。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/components/NodeWrapper/index.tsx#L184

step2 ボードにノードを配置する

step1で作成したノードコンポーネントをボードに配置します。
Board.tsx でノードの配列を定義して動的にノードを配置しています。

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.css
.board {
  position: relative;
  width: 100vw;
  height: 100vh;
}

ボードコンポーネントはApp.tsxで読み込んで表示します。

App.tsx
import { Board } from "./board/Board";
import "./App.css";

export default function App() {
  return <Board />;
}
🧩 React Flow の実装

React Flow でのボードはGraphViewコンポーネントです。ここでノード、エッジを配置しています。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/container/GraphView/index.tsx#L152-L192

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 が更新された時の不要な再レンダリングを防止することができます。
また、useStatesetState 後の再レンダリングの完了後に値が更新されるので、イベントハンドラ内で参照すると更新前の値を参照してしまうことがあります。useRef はコンポーネントのレンダリングサイクルと結びついていないので、参照時に常に最新の値を参照することができます。

useRef を値の参照に使う場合の注意点などは公式ドキュメントに分かりやすい解説が書かれています。
https://ja.react.dev/reference/react/useRef#referencing-a-value-with-a-ref

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

NodeComponent.tsx
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の中でドラッグされた時の座標更新を行っています。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/hooks/useMoveSelectedNodes.ts

step4 エッジを実装する

ノードを結ぶエッジを実装します。
座標を props で受け取るところはノードと同じですが、以下の2点が大きく違います。

  • 始点と終点を座標で指定する
  • <svg> を使用する

エッジは始点と終点が可変なのでそれぞれの座標を props で受け取ります。また、始点と終点を結ぶ線を引くために <svg> を使用します。

EdgeComponent.tsx
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が定義されています。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/components/Edges/index.ts

step5 ノードからエッジを伸ばす

ドラッグの開始

NodeComponent のコネクターのonMouseDownコールバックから Board にドラッグが開始されたノードのIDとコネクターの絶対位置が渡されます。Board ではコネクターの位置をエッジの始点と終点にセットします。

ドラッグ

ノードと同じく、エッジも props で座標を渡しているので、この座標をマウスに追従する形で更新すればドラッグすることができます。エッジの場合は始点はコネクター固定で終点をマウスに合わせて動かすので終点に対してmovementX, movementYを加算します。

NodeComponent.tsx
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>
  );
};
Board.tsx
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コンポーネントとして表現されています。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/container/GraphView/index.tsx#L170-L175

step6 エッジをノードに繋げる

エッジをドラッグしてコネクターに終点が重なったときに接続する処理を実装していきます。

エッジをコネクターに接続する

エッジをドラッグして、コネクターの上まできた時、コネクターのonMouseEnterコールバックから Board にノードのIDとコネクターの絶対位置が渡されます。Board ではコネクターの位置をエッジの終点にセットします。

エッジが接続された時に始めて、正式にエッジが作成されたことになるのでsetEdgesでエッジを追加しています。このとき、ドラッグ表示用の一時的なエッジを消すため setNewEdge(null);を実行しています。

ノードを動かした時にエッジが追従するようにする

ノードが動かされたとき、ノードに接続されているエッジの端点を更新してノードに追従させる必要があります。この処理はノードの座標更新と同じで、bord要素に設定されたonMouseMoveイベントの中で行います。すべてのエッジの中から、ドラッグしているノードに接続しているエッジを見つけて、始点、終点を更新します。

NodeComponent.tsx
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>
   );
Board.tsx
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でエッジが接続されたときに新規エッジを追加する処理が書かれています。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/components/Handle/index.tsx#L99-L113

エッジの始点、終点はsourceX, sourceY, targetX, targetYという変数名でエッジに渡されます。この値はノードの中にあるHandleComponentを参照しており、ノードの位置が変わるとリアクティブに更新され、エッジがノードに追従するようになっています。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/components/EdgeWrapper/index.tsx#L211-L227

step7 エッジのカスタマイズ

直線だったエッジをカスタマイズします。エッジはsvgのパスで書かれているので自由にカスタマイズすることが可能です。ここでは3次ベジェ曲線を使ってなめらかな曲線で結ぶように変更します。

EdgeComponent.tsx
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/にあります。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/system/src/utils/edges/bezier-edge.ts#L95-L138

パフォーマンスの改善

今回の実装だと Board ですべてのノード、エッジの state を管理しているため、1つのノードの state を更新するとBoard内のすべてのノード、エッジが再レンダーされてしまします。これは特にドラッグのときに問題となります。ドラッグ中は1秒間に何回も位置が更新されるので、ノード、エッジが多くあり、かつその中で重い計算を行っているとパフォーマンスが大きく低下します。

そこで

  • ノード、エッジをメモ化する
  • ノード座標の state 管理をボードで行わず、各ノードで行う

などの対応を行うことでパフォーマンス低下を防ぐことができます。

🧩 React Flow の実装

ノード、エッジをメモ化をした上で、ノードのドラッグによる再レンダリングの影響がそのドラッグしているノードだけになるように state 設計をしています。

NodeRendererComponentが全ノードを管理しているコンポーネントです。このコンポーネントは全ノードのIDのリストに変更があった時のみ再実行され、単一ノードの位置更新では再実行されないようになっています。つまり、ノードのドラッグでは再実行されず、ノードの追加、削除などの時のみ再実行されます。ノードの追加、削除はドラッグの時のように state が何度も更新されることはないのでパフォーマンスへの影響は少ないです。
https://github.com/xyflow/xyflow/blob/467223b9a321f6af4555244de0990ea2b766cd0c/packages/react/src/container/NodeRenderer/index.tsx#L44-L95

まとめ

以下、フローチャートUI実装の要点です。

  • ノード、エッジは座標をマウス位置で更新することでドラッグ&ドロップを実装する
  • エッジはsvgで実装して始点、終点を座標で指定する
  • エッジにはドラッグ中の表示用のエッジと接続後に存在が確定した2つ状態がある
  • ドラッグの時は再レンダリングが多くなりがちなのでパフォーマンスの工夫が必要

フローチャートUIは一見かなり複雑ですが、コア部分の実装は意外とシンプルでした。また、React Flowの機能の豊富さに驚きました。フローチャートUIを組み込みたいときは React Flow を今後も使っていこうと思います。

この記事で実装したコードはGitHubで公開しています。

https://github.com/kult0922/mini-mini-react-flow

AI Shift Tech Blog

Discussion

手羽先手羽先

とても面白かったです!参考にさせていただきます。