対象読者と動作環境
React Flow(@xyflow/react)を、自前の状態管理ストアと完全 controlled で組み合わせて使いたい人が対象です。uncontrolled(useNodesState / useEdgesState に任せる)構成の話ではありません。
検証環境は以下のとおりです。
-
@xyflow/react^12.11.2 -
react^19.2.7 -
zustand^5.0.14 -
typescript~5.9.3 -
vite^8.1.1
題材は、ホワイトボード・ドキュメント・チャット・AI出力を1つの画面に統合するチーム用ワークスペース Kurari のボード機能です。ボードのノード・エッジは複数クライアント間で WebSocket 同期する必要があるため、React Flow 内部の state ではなく Zustand ストア(entity-store)を単一の真実として持ち、BoardMode.tsx から nodes / edges を完全 controlled で渡しています。なぜ Zustand で自前管理しているかという設計判断そのものは Zenn の別記事で扱うので、この記事では React Flow 側の実装 Tips に絞ります。
controlled で渡す形の全体像
BoardMode.tsx では、Zustand ストアの nodes(KNode のツリー)から useMemo で FlowNode[] を毎回組み立てて <ReactFlow nodes={flowNodes} /> に渡しています。
// frontend/src/components/board/BoardMode.tsx L231-296(抜粋)
// ストア → React Flow ノード(controlled)
// measured を明示的に渡す: controlled 運用では dimensions 変更を書き戻さない限り
// React Flow の nodesInitialized が true にならず、パン等が永久に無効化されるため。
// 寸法は自前管理(w/h)なのでそのまま渡してよい。
const flowNodes: FlowNode[] = useMemo(() => {
if (!activeBoardId) return []
const itemToFlow = (n: KNode, parentSectionId?: string): FlowNode => {
// ...種別ごとの分岐(drawing / image / comment_pin / sticky 等)
const d = stickyData(n)
return {
id: n.id,
type: n.type,
position: { x: d.x, y: d.y },
data: { text: d.text, color: d.color, translucent: d.translucent, kind: d.kind },
selected: selectedIds.includes(n.id),
width: d.w,
height: d.h,
measured: { width: d.w, height: d.h },
...parent,
}
}
// ...
}, [nodes, activeBoardId, selectedIds])
エッジ側も同じ形で、Zustand の edges から FlowEdge[] を組み立てます(frontend/src/components/board/BoardMode.tsx L338-357)。
ポイントは「ストアが正、React Flow に渡す flowNodes / flowEdges は毎レンダー導出される派生値」という一方向の流れです。これを崩す(React Flow 側の変更をそのままストアへ全部書き戻す)と、後述する無限ループの原因になります。
measured を渡さないと詰む話
FlowNode を組み立てる際、width / height に加えて measured: { width, height } を明示的に渡しているのが最初のポイントです。
React Flow v12 は、ノードの寸法を ResizeObserver で計測してから内部の node.measured.width / node.measured.height に書き込みます(useNodesInitialized のドキュメント)。useNodesInitialized() はこの計測が全ノード分完了するまで false を返し続けます。
uncontrolled 運用ならこの計測は React Flow が自動でやってくれますが、controlled 運用で onNodesChange の dimensions 変更をストアへ書き戻していないと、計測結果がストアに反映されないまま flowNodes が再構築され続け、nodesInitialized がいつまでも false になります。Kurari の場合、寸法は元々ストアの w / h として自前管理しているので、measured にそのまま渡してしまえば計測を待たずに済みます。
nodesInitialized が false のままだと何が壊れるかというと、fitView や setCenter を使う処理が黙って動かなくなります。Kurari では、ツリーの項目をクリックしてボード上の該当要素へジャンプする「パン要求」がこれに依存しています。
// frontend/src/components/board/BoardMode.tsx L359-371
// ツリー側からのパン要求。
// React Flow の初期化(ノード計測)が済む前に setCenter を呼ぶと
// 内部ストアが無限再レンダリングに陥るため、nodesInitialized を待つ。
useEffect(() => {
if (!panRequestId || !nodesInitialized) return
const target = nodes[panRequestId]
if (target && (BOARD_ITEM_TYPES.includes(target.type) || target.type === 'section')) {
const d = stickyData(target)
const abs = absoluteXY(nodes, target)
setCenter(abs.x + d.w / 2, abs.y + d.h / 2, { zoom: 1.1, duration: 400 })
}
clearPanRequest()
}, [panRequestId, nodesInitialized, nodes, setCenter, clearPanRequest])
nodesInitialized が true にならないと、この useEffect は if (!panRequestId || !nodesInitialized) return で毎回早期リターンし、clearPanRequest() も呼ばれません。ジャンプが効かないだけで例外は出ないので、原因に気づきにくいのが厄介なところです。初期表示のフィット処理(L219-229)も同じガードを持っています。
controlled で自前の寸法管理を持っているなら、measured を渡すのは実質コストゼロです。渡し忘れに気づきにくい分、最初から習慣にしておくのが安全です。
onNodesChange で store に戻す変更を絞る理由
2つ目のポイントは onNodesChange の扱いです。React Flow から降ってくる NodeChange[] を全部ストアに書き戻すと、「ストア変更 → flowNodes 再構築 → React Flow が変更を検知 → onNodesChange 発火 → ストア変更」の無限ループになります。
Kurari の onNodesChange は、ユーザー操作由来の3種類の変更だけを選んで反映しています。
// frontend/src/components/board/BoardMode.tsx L380-460(抜粋)
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
// controlled運用: ユーザー操作由来の変更のみストアへ反映する。
// マウント/レイアウト時の position イベントや、内部リコンサイルの選択イベントを
// 書き戻すと store→ReactFlow→store の無限ループになるため扱わない。
// (選択は onSelectionChange ではなく、ここの 'select' 変更だけで同期する)
// 1) ドラッグ中の位置をライブ反映(永続化はドラッグ終了時の onNodeDragStop)
const dragChanges = changes.filter(
(c): c is Extract<NodeChange, { type: 'position' }> =>
c.type === 'position' && !!c.position && c.dragging === true,
)
if (dragChanges.length > 0) {
useEntityStore.setState((s) => {
const next = { ...s.nodes }
for (const c of dragChanges) {
const n = next[c.id]
if (n) next[c.id] = { ...n, data: { ...n.data, x: c.position!.x, y: c.position!.y } }
}
return { nodes: next }
})
}
// 2) リサイズ中のサイズをライブ反映(永続化はリサイズ終了時の onResizeEnd)
const dimChanges = changes.filter(
(c): c is Extract<NodeChange, { type: 'dimensions' }> =>
c.type === 'dimensions' && !!c.dimensions && c.resizing === true,
)
// ...同様に w/h をライブ反映
// 3) ユーザーのクリック・範囲選択による選択変更
const selectChanges = changes.filter(
(c): c is Extract<NodeChange, { type: 'select' }> => c.type === 'select',
)
// ...selectedIds を更新
},
[setSelected],
)
条件式に注目すると、position 変更は c.dragging === true のときだけ、dimensions 変更は c.resizing === true のときだけ拾っています。React Flow は初回マウント時の位置確定や内部の再計算でも position / dimensions タイプの NodeChange を発火することがありますが、dragging / resizing フラグが立っていないものは無視することで、ユーザーが実際にドラッグ・リサイズしている最中の変更だけを通しています。
select タイプは常時拾っていますが、書き込み先は useUiStore の selectedIds であって flowNodes の再構築元である useEntityStore の nodes ではありません(selected フィールドは selectedIds.includes(n.id) から毎回導出されるだけです)。コメントにもあるとおり、onSelectionChange ではなくここの select 変更だけで選択状態を同期しているのも、二重の同期経路を作らないための判断です。
永続化と undo/redo は drag/resize 終了時にまとめる
onNodesChange でストアに反映しているのはドラッグ中・リサイズ中の「見た目上のライブ反映」だけで、サーバーへの永続化と undo/redo スタックへの登録は行っていません。永続化は onNodeDragStop とリサイズ完了時(NodeResizer の onResizeEnd)でまとめて行います。
// frontend/src/components/board/BoardMode.tsx L511-572(抜粋)
/** ドラッグ終了時の永続化。複数選択の一括移動にも対応し、undo/redo は1操作にまとめる */
const persistDraggedNodes = useCallback(
(draggedNodes: FlowNode[]) => {
const snapshot = useEntityStore.getState().nodes
const ops: { undo: () => void; redo: () => void }[] = []
for (const node of draggedNodes) {
const start = dragStartRef.current[node.id]
delete dragStartRef.current[node.id]
const end = { x: node.position.x, y: node.position.y }
const kn = snapshot[node.id]
if (!kn) continue
const moved = !start || start.x !== end.x || start.y !== end.y
// ...セクション所属の付け替え判定(省略)
void updateNode(node.id, { data: end })
if (moved && start) {
ops.push({
undo: () => void updateNode(node.id, { data: start }),
redo: () => void updateNode(node.id, { data: end }),
})
}
}
if (ops.length > 0) {
useHistoryStore.getState().push({
undo: () => { for (const op of ops) op.undo() },
redo: () => { for (const op of ops) op.redo() },
})
}
},
[updateNode, activeBoardId, sectionAt, getInternalNode],
)
const onNodeDragStop = useCallback(
(_e: unknown, node: FlowNode, draggedNodes?: FlowNode[]) => {
persistDraggedNodes(draggedNodes?.length ? draggedNodes : [node])
},
[persistDraggedNodes],
)
複数ノードを同時にドラッグした場合でも draggedNodes をまとめて処理し、ops を1つの history-store エントリに束ねています。ドラッグ中の各 position change ごとに undo エントリを積んでしまうと、1回のドラッグ操作を Ctrl+Z で1手ずつしか戻せず、UX として破綻するためです。
リサイズ側も同じ構造で、NodeResizer(React Flow が提供するリサイズハンドル UI コンポーネント)の onResizeStart で開始時の寸法を ref に控え、onResizeEnd で確定・永続化しています。
// frontend/src/components/board/BoardNodes.tsx L241-263(抜粋)
<NodeResizer
isVisible={canEdit && !!selected}
keepAspectRatio={keepAspectRatio}
minWidth={minWidth}
minHeight={minHeight}
onResizeStart={(_e, params) => {
startRef.current = { x: params.x, y: params.y, w: params.width, h: params.height }
}}
onResizeEnd={(_e, params) => {
const next = { x: params.x, y: params.y, w: params.width, h: params.height }
void updateNode(id, { data: next })
const prev = startRef.current
startRef.current = null
if (prev) {
useHistoryStore.getState().push({
undo: () => updateNode(id, { data: prev }),
redo: () => updateNode(id, { data: next }),
})
}
}}
/>
history-store(frontend/src/stores/history-store.ts)自体は薄い実装です。各エントリが自分自身の undo/redo ロジック(=ストアへの呼び出し)を持つコマンドパターンで、past / future の2本のスタックを積み替えるだけになっています。
// frontend/src/stores/history-store.ts L1-54(抜粋)
export interface HistoryEntry {
undo: () => void | Promise<void>
redo: () => void | Promise<void>
}
export const useHistoryStore = create<HistoryState>((set, get) => ({
past: [],
future: [],
push: (entry) =>
set((s) => ({ past: [...s.past, entry].slice(-MAX_HISTORY), future: [] })),
undo: async () => {
const { past } = get()
if (past.length === 0) return
const entry = past[past.length - 1]
set({ past: past.slice(0, -1) })
await entry.undo()
set((s) => ({ future: [...s.future, entry] }))
},
// redo も対称の実装
clear: () => set({ past: [], future: [] }),
}))
React Flow の変更検知に依存せず「呼び出し側が能動的に push する」形にしてあるので、ドラッグ・リサイズに限らず要素の作成・削除・色変更など、ボードの操作を追加するたびに undo 対応を入れる箇所が明確になります。実際、Kurari では createItem / onDelete / recolorSelected などボード操作の各関数がそれぞれ useHistoryStore.getState().push(...) を呼んでいます(frontend/src/components/board/BoardMode.tsx)。
エッジの選択状態は自前 state で持つ
3つ目は、エッジの選択状態です。ノードは selected フィールドを FlowNode に含められますが、React Flow はエッジの選択状態そのものは内部で保持しないため、BoardMode 側でローカル state として管理しています。
// frontend/src/components/board/BoardMode.tsx L195
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([])
拾い方はノードの select 変更と同じパターンで、onEdgesChange に来る EdgeChange[] のうち type === 'select' のものだけを見ます。
// frontend/src/components/board/BoardMode.tsx L463-476
// エッジは選択変更のみ扱う(削除は onDelete、形状は BoardEdge が担う)
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
const selectChanges = changes.filter(
(c): c is Extract<EdgeChange, { type: 'select' }> => c.type === 'select',
)
if (selectChanges.length === 0) return
setSelectedEdgeIds((current) => {
const next = new Set(current)
for (const c of selectChanges) {
if (c.selected) next.add(c.id)
else next.delete(c.id)
}
return [...next]
})
}, [])
selectedEdgeIds は flowEdges を組み立てる useMemo の依存配列に入っていて、selected: selectedEdgeIds.includes(e.id) として FlowEdge に書き戻されます(frontend/src/components/board/BoardMode.tsx L338-357)。エッジの選択が曲げハンドルやツールバーの表示切り替えに使われるので、この state が無いと「クリックしたのに編集 UI が出ない」状態になります。
EdgeLabelRenderer は zIndex と pointerEvents を明示する
最後は EdgeLabelRenderer まわりです。React Flow のエッジ本体(<BaseEdge> の SVG パス)とは別に、ラベルやハンドルのような HTML 要素をエッジ上に重ねたいときに使うコンポーネントで、公式ドキュメントにも "The <EdgeLabelRenderer /> has no pointer events by default. If you want to add mouse interactions you need to set the style pointerEvents: 'all'" と明記されています。
Kurari の BoardEdge(frontend/src/components/board/BoardEdge.tsx)では、ラベルに加えて端点ハンドル・曲げハンドル・折れ線のセグメントハンドル・線種変更ツールバーをすべて EdgeLabelRenderer の中に描画しています。これらは素の状態だとポインタイベントを受け取れないだけでなく、ノードレイヤーより下に描画されてノードに隠れてクリックできなくなります。対処として、クリック可能な要素にはすべて pointerEvents: 'all' と zIndex: 1100 を明示しています。
// frontend/src/components/board/BoardEdge.tsx L646-676(抜粋)
<EdgeLabelRenderer>
{label ? (
<div
className="absolute rounded bg-white/90 px-1.5 py-0.5 text-[11px] text-neutral-700 shadow-sm"
style={{ transform: `translate(-50%,-50%) translate(${labelPos.x}px,${labelPos.y}px)`, zIndex: 1100 }}
>
{label}
</div>
) : null}
{canEdit && selected && (
<>
{(
[
['source', sp],
['target', tp],
] as const
).map(([end, p]) => (
<div
key={end}
className="nodrag nopan absolute h-3.5 w-3.5 cursor-move rounded-full border-2 border-sky-500 bg-white shadow"
style={{
transform: `translate(-50%,-50%) translate(${p.x}px,${p.y}px)`,
pointerEvents: 'all',
zIndex: 1100,
}}
onPointerDown={onEndPointerDown(end)}
onPointerMove={onEndPointerMove}
onPointerUp={onEndPointerUp}
/>
))}
{/* 曲線の中央ハンドル、折れ線のセグメントハンドル、線種/色/太さのツールバーも同じく
pointerEvents: 'all' と zIndex: 1100 を付けている */}
</>
)}
</EdgeLabelRenderer>
1100 という値自体に特別な意味はなく、Kurari 側でノードに割り当てている zIndex(通常ノードは未指定 = 0 相当、コメントピンが 1000、セクションが -10000。いずれも frontend/src/components/board/BoardMode.tsx L280, L324 の flowNodes 生成部分を参照)より確実に大きい値として決めています。ラベルやハンドルをノードより手前に出したい場合は、自分のアプリでノードに割り振っている zIndex の最大値を把握したうえで、それより大きい値を選ぶ必要があります。
また nodrag nopan クラスが付いているのも見落としやすいポイントです。React Flow はこれらのクラスが付いた要素の上でのポインタ操作を、ノードドラッグやキャンバスパンとして扱わないようにします。付け忘れると、ハンドルをつかんだつもりがキャンバスごとパンしてしまいます。
Kurari の記事一覧
- Zenn: Kurariを1ヶ月作って分かったこと
- Zenn: ボード・ドキュメント・チャットを1本のツリーで持つ設計
- Qiita: バックエンドがLLMを実行しないAIジョブキューの実装 ― Kurariのジョブキューとローカル実行Agent
- Qiita: LAN内WebRTC P2P通話を実装してハマったところ(近日公開)
- Qiita: LAN共有をオーナー承認制にする実装(Kotlin/Spring Boot + React)(近日公開)