📘

ReactFlow でリアルタイム共同編集できるフローエディタを作る

に公開

こんにちは、AI Shiftの@Jabelicです。この記事はAI Shift Advent Calendar 2025の18日目の記事です。

今回は ReactFlow によるリアルタイム共同編集可能なフローチャートエディタを作りたいと思い、特にリアルタイム共同編集をどのように実現できるのかを試してみました。WebSocket あるいは WebRTC を使えば良さそうというのはなんとなく想像がつきますが、複数ユーザーが同時に編集した際のコンフリクトをどう解決すればよいか分からず、今回調べてみることにしました。
なお本稿では「ReactFlow × Yjs でノード/エッジを共同編集し、Awarenessでカーソル共有し、GoのWebSocketパススルーで中継・永続化する」ところまでを扱い、厳密な競合ポリシー設計や性能検証・セキュリティ設計の網羅は範囲外とします。

動作確認動画

https://reactflow.dev/

https://yjs.dev/

コンフリクトと整合性の扱い

複数ユーザーが同時にドキュメントを編集する場合、以下のような課題が発生します

  • コンフリクトの発生: 2 人のユーザーが同時に同じノードを移動させたらどうなるか?
  • 順序保証: サーバーに対して操作の到着順序が異なる場合の整合性をどう保つか?

これらの課題を解決する方法として、OT, CRDT が挙げられます。

OT(Operational Transformation)

Google ドキュメントなどで使われている技術で、ユーザーの操作(挿入・削除)を記録し、他のユーザーの操作と「変換(transform)」して適用する仕組みです。例えば、位置 5 に「A」を挿入する操作と位置 3 で 2 文字削除する操作が同時に発生した場合、後者の影響を受けて前者の挿入位置を調整します。

テキスト編集に最適化されており、メモリ効率が良いのが特徴ですが、実装が非常に複雑(全ての操作の組み合わせを考慮する必要がある)で、バグが生じやすいという難点もあります。

CRDT(Conflict-free Replicated Data Type)

日本語で言うと「衝突のない複製データ型」で、分散システムで競合を自動解決するデータ構造です。各操作に一意な ID(タイムスタンプ + クライアント ID)を付与することで、操作の順序に依存せず、最終的に同じ状態に収束する(最終的整合性)仕組みになっています。ルールの例としては「最後に書いた人が勝つ」「タイムスタンプが大きい方が勝つ」などがあります。

CRDT には主に 2 つのタイプがあります。State-based CRDT(CvRDT) は状態全体を送信してマージし、Operation-based CRDT(CmRDT) は操作(オペレーション)を送信して適用します。

OT より実装が簡単で、オフライン編集に強い(再接続時に自動マージ)という利点がありますが、一方でメタデータ(ID、タイムスタンプ)のオーバーヘッドがあり、OT よりメモリを消費する傾向があります。

Yjs を使ってリアルタイム共同編集を実現

JS ライブラリでは Yjs や Automerge などが CRDT を実装しており、これらを使うことでリアルタイム共同編集機能を比較的簡単に実装できます。

今回は Yjs を使用します。

技術

  • フロントエンド: React + TypeScript + Vite
    • ReactFlow (フローチャート UI)
    • Yjs (CRDT 実装)
    • y-websocket (WebSocket クライアント)
  • バックエンド: Go + Echo
    • WebSocket サーバー
    • バイナリファイルで永続化(ydoc_state.bin

フロントエンド実装

1. Yjs の初期化

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/hooks/useYjs.ts#L6-L35

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L58-L62

Y.Doc と WebsocketのProvider を useMemo で作り、アンマウント時に provider.destroy() で後片付けします。

2. ステート設計

ノード・エッジの状態は、ReactのローカルStateに二重管理せず、Yjs側を唯一のソースにします。さらに、共同編集で「同じidを持つ要素が二重に入る」ような状態を避けるため、配列(Y.Array)ではなく Y.Map<id, Node> / Y.Map<id, Edge> のように idをキーにした構造で持ちます。

実装上は ydoc.getMap("nodesById") / ydoc.getMap("edgesById") を用意し、useYMapSnapshot(内部でuseSyncExternalStore)でマップのスナップショットを取得して ReactFlow に渡します。

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/hooks/useYMapSnapshot.ts#L3-L30

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L61-L74

3. 同期処理

ReactFlowの操作(ノード移動、削除、選択、エッジ追加など)はイベントとして流れてくるので、onNodesChange / onEdgesChange / onConnect といったイベントハンドラで、変更をYjsへ即座に反映します。具体的には applyNodeChanges / applyEdgeChanges で次の配列を作りつつ、Yjs側はid単位で差分更新set(id, node) / delete(id))します。

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L39-L54

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L126-L140

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L193-L210

4. Awareness(カーソル共有)

Yjs の Awareness は、ドキュメント本体の状態ではなく、一時的なユーザー情報(カーソル位置、選択範囲、ユーザー名、色など)を共有するための機能です。ドキュメントの永続的なデータには含まれず、ユーザーが切断すると自動的に削除されます。

これを使って、他ユーザーのカーソル位置をリアルタイムに表示します。

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L97-L124

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L142-L183

https://github.com/jabelic/demo-sync-floweditor/blob/main/frontend/src/components/FlowEditor.tsx#L274-L338

Awareness を使ったカーソル共有では、まず初期化時に onInitReactFlowInstance を取得しておく必要があります。これにより座標変換の screenToFlowPosition を利用でき、送信するカーソル位置をスクリーン座標ではなくフロー座標へ変換してから共有できます。スクリーン座標のまま送ると、各ユーザーのズームやパンの状態に左右されて表示位置がずれるためです。受信側では、共有されたフロー座標を flowToScreenPosition でスクリーン座標に戻して描画すれば、常にそのユーザーの表示状態に合わせた正しい位置にカーソルが現れます。なお、今回のデモではユーザー名と色はランダムに割り当てています。

バックエンド実装

通常、Yjs のバックエンドには Node.js の y-websocket サーバーが使われますが、自社サービスのバックエンドが Go なので、Go での実装を検証しました。

パススルー方式

Go で Yjs を直接扱えるライブラリは少なくとも筆者の調査範囲では見当たらなかったため、サーバーサイドで CRDT の中身を解釈せず、バイナリメッセージをそのままブロードキャストする方式を採用します。

https://github.com/jabelic/demo-sync-floweditor/blob/main/backend/handlers/websocket.go#L122-L144

Yjs Sync Protocol のメッセージタイプ:

  • タイプ 0(Sync Step 1): クライアントが状態ベクトルを送信
  • タイプ 1(Sync Step 2): サーバーまたは他クライアントが差分を送信(今回のパススルー実装では、他クライアントのメッセージをそのまま転送)
  • タイプ 2(Update): クライアントの変更(これだけファイルに保存)

参考:
https://github.com/yjs/y-protocols/blob/master/PROTOCOL.md

永続化(バイナリファイル)

https://github.com/jabelic/demo-sync-floweditor/blob/main/backend/handlers/websocket.go#L15-L20

https://github.com/jabelic/demo-sync-floweditor/blob/main/backend/handlers/websocket.go#L218-L235

バイナリデータ ([]byte) をそのままファイルに保存し、サーバー起動時に自動ロード、さらに 30 秒ごとに自動保存することでデータロスを抑えています。

このようにして実装すると以下のように動作します:
動作確認動画

デモ実装はこちらにありますのでご参照下さい:
https://github.com/jabelic/demo-sync-floweditor

まとめ

Yjs(CRDT)を採用することで、複雑な同期ロジックを書かずにリアルタイム共同編集を実現できました。パススルー方式によりサーバー側の処理は最小限に抑えた実装になりました。validationはできませんが運用コストは小さくできます。

今後の課題としては、Redis Pub/Sub を用いたスケールアウトや、Rust 製 y-crdt などを CGO 経由で組み込むサーバーサイドでのvalidationを検討したいのですが、CGO経由ではメモリリークなどが不安で、他の手立てがないかなーと模索しています。バックエンドをNode.jsで書けばこの辺の課題は解決できそうです。

最後に

AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)

【面談フォームはこちら】
https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459

AI Shift Tech Blog

Discussion