T-CREATOR

CRDT × Zustand:Y.js/Automerge 連携でリアルタイム共同編集を設計

CRDT × Zustand:Y.js/Automerge 連携でリアルタイム共同編集を設計

Google Docs のような複数人で同時にドキュメントを編集できる機能を、自分のアプリケーションにも実装したいと思ったことはありませんか?リアルタイム共同編集は、今や多くの Web アプリケーションで求められる重要な機能となっています。

本記事では、React の軽量状態管理ライブラリ Zustand と、競合解決を自動化する CRDT(Conflict-free Replicated Data Type) を組み合わせて、リアルタイム共同編集を実現する設計手法をご紹介します。特に、実績のある CRDT ライブラリである Y.jsAutomerge を用いた実装パターンを、コード例とともに詳しく解説していきますね。

背景

CRDT とは

CRDT(Conflict-free Replicated Data Type)は、分散システムにおいてデータの競合を自動的に解決するデータ構造です。複数のユーザーが同時に同じデータを編集しても、特別な調整なしに最終的に一貫性のある状態に収束します。

従来の楽観的ロックや悲観的ロックといった競合制御と異なり、CRDT は数学的に証明された交換可能性(Commutativity)を持ちます。つまり、操作の適用順序が異なっても、最終的な結果は同じになるのです。

Zustand の特徴

Zustand は React 向けの状態管理ライブラリで、Redux や Recoil と比べて非常にシンプルな API を提供しています。以下のような特徴があります。

  • Provider ラッパーが不要
  • ボイラープレートコードが少ない
  • TypeScript との相性が良い
  • ミドルウェアによる拡張性
  • 軽量(約 1KB)

この軽量さと柔軟性が、CRDT ライブラリとの連携に適しているのです。

Y.js の概要

Y.js は、最も成熟した JavaScript/TypeScript 用の CRDT ライブラリの一つです。以下の強みがあります。

  • 高いパフォーマンス
  • 豊富なデータ型(Text、Array、Map など)
  • WebSocket、WebRTC などの通信プロトコルに対応
  • Quill、Monaco Editor などのエディタとの統合

特にテキスト編集における CRDT 実装は非常に洗練されており、Google Docs のような体験を実現できます。

Automerge の特徴

Automerge は、JSON ライクなデータ構造を扱える CRDT ライブラリです。以下の特徴を持っています。

  • 自動的な競合解決
  • 完全な編集履歴の保持
  • オフライン対応
  • イミュータブルなデータ構造

Automerge は、ドキュメント全体を JSON として扱えるため、複雑なアプリケーション状態の同期に適しています。

CRDT と状態管理の関係性

以下の図は、CRDT と状態管理ライブラリ(Zustand)がどのように連携して、リアルタイム共同編集を実現するかを示しています。

mermaidflowchart TB
  user1["ユーザー A"] --> ui1["React UI<br/>コンポーネント"]
  user2["ユーザー B"] --> ui2["React UI<br/>コンポーネント"]

  ui1 --> zustand1["Zustand Store"]
  ui2 --> zustand2["Zustand Store"]

  zustand1 --> crdt1["CRDT ドキュメント<br/>(Y.js/Automerge)"]
  zustand2 --> crdt2["CRDT ドキュメント<br/>(Y.js/Automerge)"]

  crdt1 <-->|同期| network["ネットワーク<br/>(WebSocket/WebRTC)"]
  crdt2 <-->|同期| network

  crdt1 -->|変更通知| zustand1
  crdt2 -->|変更通知| zustand2

この図から分かるように、各ユーザーのクライアントは独立した CRDT ドキュメントを持ち、ネットワーク経由で変更を同期します。Zustand は React UI と CRDT ドキュメントの橋渡し役を担います。

図のポイント

  • 各クライアントが独自の CRDT ドキュメントを保持
  • Zustand が UI と CRDT の間のインターフェースとなる
  • ネットワーク経由で CRDT の変更が自動的に同期される

課題

従来の状態管理における競合問題

通常の状態管理ライブラリ(Redux、Recoil、Zustand など)は、シングルクライアントでの状態管理には優れていますが、複数クライアント間でのリアルタイム同期には対応していません。

例えば、以下のような問題が発生します。

問題 1:Last Write Wins(最後の書き込みが勝つ)

ユーザー A とユーザー B が同時にテキストを編集した場合、後から適用された変更が前の変更を上書きしてしまいます。これでは、片方のユーザーの編集内容が失われてしまいますね。

問題 2:バージョン競合

サーバー側でバージョン管理を行う場合、クライアント間のバージョンの不一致により、更新が拒否されることがあります。これではユーザー体験が損なわれます。

問題 3:ネットワーク遅延時の不整合

ネットワーク遅延がある環境では、各クライアントが異なる状態を表示し続ける可能性があります。

リアルタイム同期の複雑性

リアルタイム同期を実装するには、以下のような複雑な課題があります。

mermaidstateDiagram-v2
  [*] --> LocalEdit: ユーザーが編集
  LocalEdit --> Validation: 検証
  Validation --> SendToServer: サーバーへ送信
  SendToServer --> WaitResponse: 応答待ち

  WaitResponse --> Conflict: 競合検出
  WaitResponse --> Success: 成功

  Conflict --> Merge: マージ処理
  Merge --> ApplyChanges: 変更適用

  Success --> ApplyChanges
  ApplyChanges --> [*]

  WaitResponse --> NetworkError: ネットワークエラー
  NetworkError --> Retry: リトライ
  Retry --> SendToServer

従来のアプローチで必要な処理

  • 競合検出ロジックの実装
  • マージアルゴリズムの設計
  • ロールバック・リトライ機構
  • ネットワークエラー処理

これらを一から実装するのは、非常に困難で時間がかかります。

Zustand 単体での限界

Zustand は優れた状態管理ライブラリですが、以下の点で限界があります。

#項目Zustand 単体CRDT との連携
1競合解決手動実装が必要自動解決
2オフライン対応複雑な実装標準機能
3操作履歴別途実装組み込み済み
4ネットワーク同期カスタムロジックプロトコル提供
5タイムトラベル自前実装CRDT が対応

このように、Zustand だけではリアルタイム共同編集に必要な機能が不足しています。CRDT との連携により、これらの課題を解決できるのです。

型安全性の維持

TypeScript を使用する場合、CRDT ライブラリと Zustand の間で型の整合性を保つことが課題となります。CRDT ドキュメントの構造と、Zustand の state 型が一致していないと、実行時エラーが発生する可能性があります。

解決策

CRDT と Zustand を組み合わせる設計思想

CRDT と Zustand を組み合わせることで、以下のような役割分担が可能になります。

Zustand の役割

  • React コンポーネントへの状態提供
  • ローカルでの高速な状態更新
  • TypeScript による型安全性の保証
  • ミドルウェアによる拡張機能(ロギング、永続化など)

CRDT の役割

  • 競合の自動解決
  • ネットワーク経由の同期
  • 操作履歴の管理
  • オフライン対応

この組み合わせにより、開発者はシンプルな Zustand の API を使いながら、裏側で CRDT が複雑な同期処理を担当するという、美しいアーキテクチャが実現できます。

アーキテクチャパターン

以下の図は、推奨するアーキテクチャパターンを示しています。

mermaidflowchart TD
  subgraph Client ["クライアント側"]
    ReactUI["React UI<br/>コンポーネント"]
    ZustandStore["Zustand Store<br/>(Proxy Layer)"]
    CRDTDoc["CRDT ドキュメント<br/>(Source of Truth)"]

    ReactUI -->|read/subscribe| ZustandStore
    ReactUI -->|action| ZustandStore
    ZustandStore -->|update| CRDTDoc
    CRDTDoc -->|observe| ZustandStore
  end

  subgraph Network ["ネットワーク層"]
    Provider["Sync Provider<br/>(WebSocket/WebRTC)"]
  end

  subgraph Server ["サーバー側"]
    SyncServer["同期サーバー"]
    Persistence["永続化層<br/>(オプション)"]
    SyncServer --> Persistence
  end

  CRDTDoc <-->|sync| Provider
  Provider <-->|sync| SyncServer

設計のポイント

  • CRDT ドキュメントを「信頼できる唯一の情報源(Source of Truth)」とする
  • Zustand Store は CRDT への Proxy として機能
  • React UI は Zustand を通じて間接的に CRDT を操作

双方向バインディングの実装戦略

CRDT ドキュメントと Zustand Store の双方向バインディングを実現するには、以下の戦略が有効です。

CRDT → Zustand の更新: CRDT ドキュメントの変更イベントを監視し、Zustand の state を更新します。これにより、他のユーザーの変更が自動的に React UI に反映されます。

Zustand → CRDT の更新: Zustand の action 内で CRDT ドキュメントを更新します。この時、Zustand の state も即座に更新することで、楽観的 UI 更新を実現します。

無限ループの防止: 双方向バインディングでは、更新が無限にループしないよう、フラグやバージョン管理を用いた制御が必要です。

ミドルウェアパターンの活用

Zustand のミドルウェア機能を活用して、CRDT との連携ロジックをカプセル化できます。これにより、アプリケーションコードから CRDT の詳細を隠蔽し、シンプルな API を提供できますね。

mermaidflowchart LR
  App["アプリケーション<br/>コード"] --> StoreAPI["Zustand<br/>Store API"]
  StoreAPI --> Middleware["CRDT<br/>ミドルウェア"]
  Middleware --> BaseStore["Base Store"]
  Middleware <--> CRDTLib["CRDT<br/>ライブラリ"]

ミドルウェアが提供する機能

  • CRDT ドキュメントの初期化
  • 同期プロバイダーの設定
  • 変更イベントのハンドリング
  • エラーハンドリング

具体例

Y.js と Zustand の連携実装

ここからは、実際のコード例を用いて、Y.js と Zustand を連携させる方法を解説します。

インストールとセットアップ

まず、必要なパッケージをインストールします。

bashyarn add zustand yjs y-websocket
yarn add -D @types/yjs

これらのパッケージにより、Zustand の状態管理機能と、Y.js の CRDT 機能、WebSocket による同期機能が利用可能になります。

型定義

TypeScript で型安全な実装を行うため、まず型定義を行います。

typescript// types.ts
import * as Y from 'yjs';

/**
 * 共有ドキュメントの状態を表す型
 * CRDT で管理される実際のデータ構造
 */
export interface SharedDocument {
  title: string;
  content: string;
  updatedAt: number;
}

/**
 * Zustand Store の状態型
 * React コンポーネントが参照する状態
 */
export interface DocumentStore {
  // 現在のドキュメント状態
  document: SharedDocument;
  // Y.js のドキュメントインスタンス(内部使用)
  ydoc: Y.Doc | null;
  // 接続状態
  isConnected: boolean;
}

この型定義により、アプリケーション全体で一貫した型安全性が保たれます。

Y.js ドキュメントの初期化

次に、Y.js のドキュメントを初期化する関数を作成します。

typescript// yjs-setup.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

/**
 * Y.js ドキュメントとプロバイダーを初期化
 * @param roomId - 共有ルームの識別子
 * @returns Y.Doc インスタンスとプロバイダー
 */
export function initializeYDoc(roomId: string) {
  // Y.js のドキュメントを作成
  const ydoc = new Y.Doc();

  // WebSocket プロバイダーを設定
  // 実際の環境では、WebSocket サーバーの URL を指定
  const provider = new WebsocketProvider(
    'ws://localhost:1234',
    roomId,
    ydoc
  );

  return { ydoc, provider };
}

WebsocketProvider は、Y.js のドキュメントを WebSocket 経由で他のクライアントと同期するための機能を提供します。

Zustand Store の作成

ここが最も重要な部分です。Y.js と Zustand を統合した Store を作成します。

typescript// store.ts
import { create } from 'zustand';
import * as Y from 'yjs';
import { DocumentStore, SharedDocument } from './types';
import { initializeYDoc } from './yjs-setup';

/**
 * Y.js の Map を SharedDocument に変換
 */
function yMapToDocument(ymap: Y.Map<any>): SharedDocument {
  return {
    title: ymap.get('title') || '',
    content: ymap.get('content') || '',
    updatedAt: ymap.get('updatedAt') || Date.now(),
  };
}

この変換関数により、Y.js の内部表現を、アプリケーションで使いやすい形式に変換します。

typescript/**
 * CRDT 対応のドキュメント Store を作成
 */
export const useDocumentStore = create<DocumentStore>(
  (set, get) => {
    let ydoc: Y.Doc | null = null;
    let ymap: Y.Map<any> | null = null;
    let updateFromYjs = false; // 無限ループ防止フラグ

    return {
      document: {
        title: '',
        content: '',
        updatedAt: Date.now(),
      },
      ydoc: null,
      isConnected: false,
    };
  }
);

初期状態を定義しています。updateFromYjs フラグは、Y.js からの更新と Zustand からの更新を区別するために使用されます。

初期化関数の実装

Store に CRDT ドキュメントを接続する初期化関数を追加します。

typescript/**
 * Y.js ドキュメントを初期化し、Zustand と接続
 */
export function initializeDocument(roomId: string) {
  const store = useDocumentStore.getState();

  // Y.js のセットアップ
  const { ydoc, provider } = initializeYDoc(roomId);
  const ymap = ydoc.getMap('document');

  // Y.js の変更を Zustand に反映
  ymap.observe((event) => {
    updateFromYjs = true;
    useDocumentStore.setState({
      document: yMapToDocument(ymap),
    });
    updateFromYjs = false;
  });

  // 初期状態を設定
  useDocumentStore.setState({
    ydoc,
    document: yMapToDocument(ymap),
  });
}

ymap.observe() により、Y.js ドキュメントの変更を監視し、Zustand の状態に反映しています。これで他のユーザーの変更が自動的に UI に反映されますね。

更新アクションの実装

ドキュメントを更新するアクション関数を作成します。

typescript/**
 * ドキュメントのタイトルを更新
 */
export function updateTitle(newTitle: string) {
  const { ydoc } = useDocumentStore.getState();
  if (!ydoc) return;

  const ymap = ydoc.getMap('document');

  // トランザクション内で更新を実行
  ydoc.transact(() => {
    ymap.set('title', newTitle);
    ymap.set('updatedAt', Date.now());
  });

  // 楽観的 UI 更新(Y.js の observe で再度更新されるが、ちらつき防止)
  if (!updateFromYjs) {
    useDocumentStore.setState({
      document: yMapToDocument(ymap),
    });
  }
}

ydoc.transact() を使用することで、複数の変更を一つのトランザクションとして扱い、効率的に同期できます。

typescript/**
 * ドキュメントの内容を更新
 */
export function updateContent(newContent: string) {
  const { ydoc } = useDocumentStore.getState();
  if (!ydoc) return;

  const ymap = ydoc.getMap('document');

  ydoc.transact(() => {
    ymap.set('content', newContent);
    ymap.set('updatedAt', Date.now());
  });

  if (!updateFromYjs) {
    useDocumentStore.setState({
      document: yMapToDocument(ymap),
    });
  }
}

同様に、コンテンツ更新用の関数も実装します。これで、ユーザーの入力が即座に CRDT ドキュメントに反映されます。

React コンポーネントでの使用

作成した Store を React コンポーネントで使用する例です。

typescript// DocumentEditor.tsx
import React, { useEffect } from 'react';
import {
  useDocumentStore,
  initializeDocument,
  updateTitle,
  updateContent,
} from './store';

export function DocumentEditor({
  roomId,
}: {
  roomId: string;
}) {
  const { document, isConnected } = useDocumentStore();

  // コンポーネントマウント時に初期化
  useEffect(() => {
    initializeDocument(roomId);
  }, [roomId]);

  return (
    <div>
      {/* 接続状態の表示 */}
      <div>{isConnected ? '✓ 接続済み' : '接続中...'}</div>
    </div>
  );
}

コンポーネントのマウント時に CRDT ドキュメントを初期化し、接続状態を表示しています。

typescript      {/* タイトル入力 */}
      <input
        type="text"
        value={document.title}
        onChange={(e) => updateTitle(e.target.value)}
        placeholder="ドキュメントタイトル"
      />

      {/* コンテンツ入力 */}
      <textarea
        value={document.content}
        onChange={(e) => updateContent(e.target.value)}
        placeholder="コンテンツを入力..."
        rows={10}
      />

      {/* 最終更新時刻 */}
      <div>
        最終更新: {new Date(document.updatedAt).toLocaleString()}
      </div>
    </div>
  );
}

入力フィールドは Zustand の状態にバインドされ、変更は自動的に CRDT ドキュメントに同期されます。非常にシンプルな API で、複雑なリアルタイム同期が実現できていますね。

Automerge と Zustand の連携実装

次に、Automerge を使った実装例を見ていきましょう。Automerge は JSON ライクなデータ構造を扱えるため、複雑な状態管理に適しています。

インストール

bashyarn add @automerge/automerge zustand
yarn add -D @types/automerge

Automerge の最新版を使用します。

型定義

typescript// automerge-types.ts
import * as Automerge from '@automerge/automerge';

/**
 * Automerge で管理するドキュメント型
 */
export interface AutomergeDocument {
  title: string;
  content: string;
  metadata: {
    createdAt: number;
    updatedAt: number;
    author: string;
  };
  collaborators: string[];
}

/**
 * Zustand Store の状態型
 */
export interface AutomergeStore {
  // Automerge ドキュメント
  doc: Automerge.Doc<AutomergeDocument> | null;
  // ローカル状態(読み取り専用)
  state: AutomergeDocument | null;
  // 同期状態
  syncState: 'disconnected' | 'connecting' | 'connected';
}

Automerge の Doc 型を使用して、型安全なドキュメントを定義します。

Automerge ドキュメントの初期化

typescript// automerge-setup.ts
import * as Automerge from '@automerge/automerge';
import { AutomergeDocument } from './automerge-types';

/**
 * 新しい Automerge ドキュメントを作成
 */
export function createDocument(
  author: string
): Automerge.Doc<AutomergeDocument> {
  return Automerge.from({
    title: '',
    content: '',
    metadata: {
      createdAt: Date.now(),
      updatedAt: Date.now(),
      author,
    },
    collaborators: [author],
  });
}

Automerge.from() を使用して、初期状態からドキュメントを作成します。

Zustand Store の作成

typescript// automerge-store.ts
import { create } from 'zustand';
import * as Automerge from '@automerge/automerge';
import {
  AutomergeStore,
  AutomergeDocument,
} from './automerge-types';
import { createDocument } from './automerge-setup';

export const useAutomergeStore = create<AutomergeStore>(
  (set, get) => ({
    doc: null,
    state: null,
    syncState: 'disconnected',
  })
);

初期状態を定義します。

初期化とアクション

typescript/**
 * Automerge ドキュメントを初期化
 */
export function initAutomerge(author: string) {
  const doc = createDocument(author);

  useAutomergeStore.setState({
    doc,
    state: doc as AutomergeDocument,
    syncState: 'connected',
  });
}

/**
 * タイトルを更新
 */
export function updateAutomergeTitle(newTitle: string) {
  const { doc } = useAutomergeStore.getState();
  if (!doc) return;

  // Automerge の change API を使用
  const newDoc = Automerge.change(doc, (draft) => {
    draft.title = newTitle;
    draft.metadata.updatedAt = Date.now();
  });

  useAutomergeStore.setState({
    doc: newDoc,
    state: newDoc as AutomergeDocument,
  });
}

Automerge.change() は、Immer のような API で、イミュータブルな更新を実現します。

typescript/**
 * 共同作業者を追加
 */
export function addCollaborator(name: string) {
  const { doc } = useAutomergeStore.getState();
  if (!doc) return;

  const newDoc = Automerge.change(doc, (draft) => {
    if (!draft.collaborators.includes(name)) {
      draft.collaborators.push(name);
    }
  });

  useAutomergeStore.setState({
    doc: newDoc,
    state: newDoc as AutomergeDocument,
  });
}

配列への追加も、通常の JavaScript と同じように記述できます。

ドキュメントの同期

Automerge では、変更をバイナリ形式で効率的に送受信できます。

typescript/**
 * 変更をエクスポート(他のクライアントに送信)
 */
export function exportChanges(): Uint8Array | null {
  const { doc } = useAutomergeStore.getState();
  if (!doc) return null;

  // ドキュメント全体をバイナリで保存
  return Automerge.save(doc);
}

/**
 * 変更をインポート(他のクライアントから受信)
 */
export function importChanges(binary: Uint8Array) {
  try {
    const doc = Automerge.load<AutomergeDocument>(binary);

    useAutomergeStore.setState({
      doc,
      state: doc as AutomergeDocument,
    });
  } catch (error) {
    console.error('Failed to import changes:', error);
  }
}

Automerge.save()Automerge.load() により、ドキュメントのシリアライズ・デシリアライズが可能です。

マージ処理

複数のクライアントからの変更をマージする処理を実装します。

typescript/**
 * 他のドキュメントとマージ
 */
export function mergeDocuments(remoteBinary: Uint8Array) {
  const { doc } = useAutomergeStore.getState();
  if (!doc) return;

  try {
    // リモートドキュメントを読み込み
    const remoteDoc =
      Automerge.load<AutomergeDocument>(remoteBinary);

    // 自動的にマージ(競合は CRDT により解決)
    const mergedDoc = Automerge.merge(doc, remoteDoc);

    useAutomergeStore.setState({
      doc: mergedDoc,
      state: mergedDoc as AutomergeDocument,
    });
  } catch (error) {
    console.error('Merge failed:', error);
  }
}

Automerge.merge() により、競合を自動的に解決しながらマージが行われます。これが CRDT の強力な機能ですね。

エラーハンドリングとデバッグ

実際の運用では、適切なエラーハンドリングが重要です。

接続エラーの処理

typescript// error-handling.ts

/**
 * WebSocket 接続エラーのハンドリング
 */
export function handleConnectionError(error: Error) {
  // エラーコード: WebSocket Connection Failed
  console.error(
    'WebSocket Connection Failed:',
    error.message
  );

  // Zustand の状態を更新
  useDocumentStore.setState({
    isConnected: false,
  });

  // ユーザーへの通知
  showNotification({
    type: 'error',
    message:
      'サーバーとの接続に失敗しました。再接続を試みています...',
  });

  // 再接続の試行
  setTimeout(() => {
    reconnect();
  }, 3000);
}

エラーコード: WebSocket Connection Failed 発生条件: WebSocket サーバーへの接続が失敗した場合 解決方法:

  1. ネットワーク接続を確認
  2. WebSocket サーバーの稼働状態を確認
  3. 自動再接続を実装

同期エラーの処理

typescript/**
 * 同期エラーのハンドリング
 */
export function handleSyncError(error: Error) {
  // エラーコード: CRDT Sync Error
  console.error('CRDT Sync Error:', error.message);

  // エラーの種類に応じた処理
  if (error.message.includes('version mismatch')) {
    // バージョン不一致の場合、フル同期を実行
    performFullSync();
  } else if (error.message.includes('invalid update')) {
    // 無効な更新の場合、ローカルの変更を破棄
    resetToRemoteState();
  }
}

エラーコード: CRDT Sync Error - version mismatch 発生条件: クライアント間でドキュメントのバージョンが一致しない場合 解決方法:

  1. フル同期を実行して状態を再構築
  2. ローカルキャッシュをクリア
  3. 必要に応じて手動でのマージを促す

デバッグ用ロギング

開発時には、詳細なログが役立ちます。

typescript/**
 * CRDT 操作のデバッグログ
 */
export function logCRDTOperation(
  operation: string,
  details: any
) {
  if (process.env.NODE_ENV === 'development') {
    console.group(`[CRDT] ${operation}`);
    console.log('Timestamp:', new Date().toISOString());
    console.log('Details:', details);
    console.log(
      'Current State:',
      useDocumentStore.getState().document
    );
    console.groupEnd();
  }
}

この関数を各操作の前後で呼び出すことで、CRDT の動作を追跡できます。

パフォーマンス最適化

メモ化による再レンダリングの抑制

typescript// optimizations.ts
import { useDocumentStore } from './store';
import { useMemo } from 'react';

/**
 * 選択的な状態購読(必要な部分のみ)
 */
export function useDocumentTitle() {
  return useDocumentStore((state) => state.document.title);
}

export function useDocumentContent() {
  return useDocumentStore(
    (state) => state.document.content
  );
}

Zustand の selector 機能により、必要な状態の変更時のみ再レンダリングが発生します。

バッチ更新

typescript/**
 * 複数の変更をバッチで適用
 */
export function batchUpdateDocument(
  updates: Partial<SharedDocument>
) {
  const { ydoc } = useDocumentStore.getState();
  if (!ydoc) return;

  const ymap = ydoc.getMap('document');

  // トランザクションで一括更新
  ydoc.transact(() => {
    Object.entries(updates).forEach(([key, value]) => {
      ymap.set(key, value);
    });
    ymap.set('updatedAt', Date.now());
  });
}

トランザクションを使用することで、ネットワークトラフィックを削減できますね。

まとめ

本記事では、CRDT(Y.js と Automerge)と Zustand を組み合わせたリアルタイム共同編集の実装方法を解説しました。

重要なポイント

  1. 役割分担の明確化: Zustand は React との連携を、CRDT は競合解決と同期を担当する設計により、シンプルで保守性の高いコードが実現できます

  2. 型安全性の確保: TypeScript の型定義により、CRDT ドキュメントと Zustand の状態の整合性を保ち、実行時エラーを防げます

  3. 選択肢の理解: Y.js はテキスト編集に特化し高パフォーマンス、Automerge は JSON 構造の扱いやすさが特徴です。用途に応じて選択しましょう

  4. エラーハンドリング: 接続エラーや同期エラーへの適切な対応により、ユーザー体験を損なわないシステムが構築できます

  5. パフォーマンス最適化: selector によるメモ化や、トランザクションによるバッチ更新で、スケーラブルなアプリケーションを実現できます

この設計パターンを活用すれば、Google Docs のようなリアルタイム共同編集機能を、比較的少ないコード量で実装できるでしょう。CRDT という強力な技術と、Zustand のシンプルさを組み合わせることで、開発体験とユーザー体験の両方を向上させることが可能です。

リアルタイム共同編集は、現代の Web アプリケーションにおいて欠かせない機能となっています。本記事で紹介した手法が、皆さんのプロジェクトに役立てば幸いです。

関連リンク