はじめに
エンジニア採用プラットフォーム saiyouba に、「システム設計面接(System Design Interview)」のための機能を実装しました。候補者がブラウザ上のホワイトボードに、ロードバランサー・API サーバー・DB・キャッシュといったコンポーネント(ノード)を配置し、それらを線(エッジ)でつないでアーキテクチャ図を描く、というものです。提出された図は、採用担当者が読み取り専用でレビューします。
UI には React Flow(@xyflow/react) を採用しました。ノードベースのエディタを作るためのライブラリで、ドラッグ移動・ズーム・ノード同士の接続といった基本機能が一通り揃っています。
実装は素直に進む…と思いきや、地味なところで何度かハマりました。中でも「ノード間の接続線が一切表示されない」という問題には 30 分ほど溶かしたので、同じように React Flow でカスタムノードのエディタを作る人向けに、原因と解決策を共有します。
この記事は特定プロダクトの宣伝ではなく、React Flow を実務で使ったときの知見の共有が目的です。コードは説明用に簡略化しています。
作ったものの概要
- サイドバーからコンポーネント(ノード)をドラッグ&ドロップで配置
- ノードのハンドル同士をつないでエッジ(接続線)を引く
- 図全体を保存(後述の通り JSONB で永続化)
- 採用担当者は同じ図を読み取り専用で閲覧
おおまかな構成は次の通りです。
import { useCallback } from "react";
import {
ReactFlow,
Background,
Controls,
useNodesState,
useEdgesState,
addEdge,
type Connection,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
const nodeTypes = { service: ServiceNode, database: DatabaseNode /* ... */ };
function Whiteboard({ readOnly }: { readOnly: boolean }) {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback(
(params: Connection) =>
// ★ style を必ず付けて追加する(理由は後述)
setEdges((eds) => addEdge({ ...params, style: { strokeWidth: 2 } }, eds)),
[setEdges],
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodesDraggable={!readOnly}
nodesConnectable={!readOnly}
elementsSelectable={!readOnly}
fitView
>
<Background />
<Controls showInteractive={false} />
</ReactFlow>
);
}
ハマりポイント①:接続線(エッジ)が表示されない【本命・30分コース】
一番苦戦したのがこれです。
症状:ノードは正しく表示される。接続操作もできているはずで、保存データにもエッジはちゃんと入っている。なのに、ノード間の線だけが一切描画されない。特に読み取り専用ビューで顕著でした。
最初は「エッジのデータが壊れているのか?」「source / target の id が違うのか?」と疑いました。が、データは正しい。次に「線の色が背景と同化しているのでは?」と色を変えてみても出ない。完全に手詰まりで 30 分溶かしました。
原因:カスタムノードのハンドルを CSS で display: none にして隠していたことでした。
読み取り専用ビューでは「接続用の点(ハンドル)」を見せたくなかったので、何気なく display: none で消していたのですが、これが致命的でした。
React Flow は、エッジの始点・終点を「ハンドルの DOM 上の位置(bounding box)」を測定して計算します。display: none の要素はレイアウトから外れ、サイズも位置も測定できなくなるため、エッジのアンカーが決まらず、結果として線が描画されません。
解決:ハンドルを「見えないが、レイアウト上は存在する」状態にします。display: none の代わりに opacity: 0(+ pointer-events: none)を使います。
/* ❌ これだと接続線が表示されない(ハンドルが測定できなくなる) */
.sd-readonly .react-flow__handle {
display: none;
}
/* ✅ レイアウト上は残し、「測定可能」なまま見えなくする */
.sd-readonly .react-flow__handle {
opacity: 0;
pointer-events: none;
}
学び:React Flow のエッジは「ハンドルの DOM 測定」に依存している。ハンドルを隠したいときは
display: noneではなくopacity: 0。visibility: hiddenもレイアウトから外れないので候補ですが、子孫側の上書きに注意。
ハマりポイント②:再読み込みするとエッジが消える
症状:保存 → 再読み込みすると、ノードは戻るのにエッジが見えない(または極端に細くて見えない)。
原因:保存時にエッジの style を落としていました。onConnect ではスタイルを付けていても、永続化のときに id / source / target しか拾っておらず、読み込み時にデフォルトスタイルのエッジに化けていたのです。
解決:エッジの style と、(カスタムハンドルを使うなら)sourceHandle / targetHandle まで含めて保存し、読み込み時に復元します。欠けていたらデフォルトを補う防御も入れておくと安全です。
// 保存:必要なフィールドを明示的に拾う
const doc = {
nodes: nodes.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})),
edges: edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
style: e.style ?? { strokeWidth: 2 }, // ★ これを保存しないと線が消える
})),
};
// 読み込み:style が欠けていてもデフォルトを補う
const loaded = JSON.parse(canvasDoc);
setNodes(loaded.nodes);
setEdges(
loaded.edges.map((e) => ({
...e,
style: e.style ?? { strokeWidth: 2 },
})),
);
ハマりポイント③:カスタムノードのハンドル id とエッジを一致させる
カスタムノードに複数のハンドル(入力用・出力用など)を置く場合、Handle に id を付け、エッジ側の sourceHandle / targetHandle と一致させる必要があります。ここがズレていると「つないだはずなのに線が出ない」ことになります。
import { Handle, Position, type NodeProps } from "@xyflow/react";
function ServiceNode({ data }: NodeProps) {
return (
<div className="sd-node">
{/* 入力ハンドル */}
<Handle type="target" position={Position.Left} id="in" />
<span className="sd-node-label">{data.label as string}</span>
{/* 出力ハンドル */}
<Handle type="source" position={Position.Right} id="out" />
</div>
);
}
保存したエッジが sourceHandle: "out" / targetHandle: "in" を持っていて、かつノード側に同じ id のハンドルが実際にレンダリングされていることが条件です(①の display: none 問題ともつながります)。
ハマりポイント④:読み取り専用(レビュー)モードの作り込み
採用担当者向けのレビュー画面は読み取り専用にします。React Flow では各種インタラクションを個別に切れます。
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
// パン・ズームは残してOK(図は自由に見たいので)
fitView
>
ポイントは、インタラクションは切っても、ハンドルの DOM は残すこと。nodesConnectable={false} はあくまで「接続できない」だけで、ハンドルの見た目は CSS の管轄です。ここで①の罠を踏みやすいので注意します。
永続化:JSONB にそのまま入れる
図のドキュメント(nodes / edges)は、PostgreSQL の JSONB カラムにそのまま保存しています。スキーマレスにノード・エッジを格納でき、図のフォーマットを変えてもマイグレーションが要らないのが利点です(イメージとして簡略化したスキーマ)。
CREATE TABLE system_design_canvases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
doc JSONB NOT NULL DEFAULT '{}'::jsonb, -- { nodes: [...], edges: [...] }
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
読み込み時に「②の style 補完」を必ず通すようにしておくと、過去に保存した古いフォーマットの図でも線がちゃんと出るようになり、安心です。
まとめ
React Flow でカスタムノードのエディタを作るときにハマったポイントと対策です。
-
接続線が出ない最大の原因は「ハンドルを
display: noneで隠していた」こと。 エッジはハンドルの DOM 測定に依存するので、隠すならopacity: 0。 -
保存時はエッジの
styleとsourceHandle/targetHandleまで永続化する。 落とすと再読み込みで線が消える/見えなくなる。 - 複数ハンドルは
idを付けてエッジと一致させる。 - 読み取り専用はインタラクションだけ切り、ハンドルの DOM は残す。
「ノードは出るのに線だけ出ない」は React Flow あるあるだと思います。同じ沼にハマった方の助けになれば幸いです。