0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

システム設計面接機能をブラウザで実装したら、接続線が出なくて30分溶かした話(React Flow / saiyouba 開発記)

0
Posted at

はじめに

エンジニア採用プラットフォーム 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: 0visibility: 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 とエッジを一致させる

カスタムノードに複数のハンドル(入力用・出力用など)を置く場合、Handleid を付け、エッジ側の 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
  • 保存時はエッジの stylesourceHandle / targetHandle まで永続化する。 落とすと再読み込みで線が消える/見えなくなる。
  • 複数ハンドルは id を付けてエッジと一致させる。
  • 読み取り専用はインタラクションだけ切り、ハンドルの DOM は残す。

「ノードは出るのに線だけ出ない」は React Flow あるあるだと思います。同じ沼にハマった方の助けになれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?