T-CREATOR

Zustand で複数ユーザー同時編集(コラボレーション)機能を作る

Zustand で複数ユーザー同時編集(コラボレーション)機能を作る

現代の Web アプリケーションでは、複数のユーザーが同時に同じドキュメントやデータを編集する機会が増えています。Google Docs や Notion のようなコラボレーションツールが当たり前になった今、リアルタイムでの同時編集機能は必須の機能となっています。

しかし、この機能を実装する際には多くの技術的課題が立ちはだかります。データの整合性を保ちながら、リアルタイムで同期を行うのは簡単ではありません。

この記事では、軽量で使いやすい状態管理ライブラリ「Zustand」を活用して、複数ユーザーが同時編集できるコラボレーション機能を実装する方法をご紹介します。実際のコード例とエラーハンドリングを含めて、実践的な実装手法をお伝えします。

Zustand の基本概念とコラボレーション機能の必要性

Zustand は、Redux や MobX に比べてシンプルで学習コストが低い状態管理ライブラリです。TypeScript との相性も良く、バンドルサイズも小さいため、モダンな React アプリケーションで広く使われています。

Zustand の特徴

  • シンプルな API: ボイラープレートコードが少ない
  • TypeScript 対応: 型安全性が高い
  • 軽量: バンドルサイズが小さい(約 2KB)
  • 柔軟性: ミドルウェアやデバウンス機能が豊富

コラボレーション機能において、Zustand の利点は状態の変更を簡単に追跡できることです。これにより、他のユーザーの操作をリアルタイムで反映しやすくなります。

基本的な Zustand ストアの構造

typescript// store/collaborationStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface User {
  id: string;
  name: string;
  color: string;
  cursorPosition?: { x: number; y: number };
}

interface Document {
  id: string;
  content: string;
  lastModified: Date;
  modifiedBy: string;
}

interface CollaborationState {
  users: User[];
  currentDocument: Document | null;
  isConnected: boolean;
  connectionError: string | null;
}

interface CollaborationActions {
  addUser: (user: User) => void;
  removeUser: (userId: string) => void;
  updateDocument: (content: string) => void;
  setConnectionStatus: (status: boolean) => void;
  setConnectionError: (error: string | null) => void;
}

type CollaborationStore = CollaborationState &
  CollaborationActions;

export const useCollaborationStore =
  create<CollaborationStore>()(
    devtools(
      (set, get) => ({
        users: [],
        currentDocument: null,
        isConnected: false,
        connectionError: null,

        addUser: (user) =>
          set((state) => ({
            users: [...state.users, user],
          })),

        removeUser: (userId) =>
          set((state) => ({
            users: state.users.filter(
              (user) => user.id !== userId
            ),
          })),

        updateDocument: (content) =>
          set((state) => ({
            currentDocument: state.currentDocument
              ? {
                  ...state.currentDocument,
                  content,
                  lastModified: new Date(),
                  modifiedBy: 'current-user', // 実際の実装では認証情報から取得
                }
              : null,
          })),

        setConnectionStatus: (status) =>
          set({ isConnected: status }),
        setConnectionError: (error) =>
          set({ connectionError: error }),
      }),
      { name: 'collaboration-store' }
    )
  );

このストアは、複数ユーザーの状態管理とドキュメントの編集履歴を管理します。devtools ミドルウェアを使用することで、Redux DevTools で状態の変化を追跡できます。

同時編集機能の技術的課題

複数ユーザーによる同時編集を実現する際には、以下の技術的課題が発生します。

1. データの整合性問題

複数のユーザーが同時に同じデータを編集すると、データの整合性が保たれなくなる可能性があります。例えば、ユーザー A が「こんにちは」を「こんにちは世界」に変更し、同時にユーザー B が「こんにちは」を「おはよう」に変更した場合、どちらの変更を優先すべきかが問題になります。

2. ネットワーク遅延と順序の問題

リアルタイム通信では、ネットワークの遅延により操作の順序が入れ替わる可能性があります。これにより、最終的な状態が予期しないものになることがあります。

3. 競合解決の複雑さ

同じ位置のテキストを複数のユーザーが同時に編集した場合、どの変更を採用するかを決定する必要があります。

4. パフォーマンスの問題

多数のユーザーが同時に編集する場合、すべての変更をリアルタイムで同期するのは計算コストが高くなります。

これらの課題を解決するために、適切なアルゴリズムとアーキテクチャを選択する必要があります。

実装アプローチの選択

同時編集機能を実装する際には、主に以下のアプローチがあります。

1. Operational Transformation (OT)

Google Docs で使用されている手法で、操作を変換して競合を解決します。

2. Conflict-free Replicated Data Types (CRDT)

分散システムで使用される手法で、競合が発生しないデータ構造を使用します。

3. ロックベースのアプローチ

一度に一人のユーザーしか編集できないようにする手法です。

この記事では、実装が比較的簡単で理解しやすい OT アプローチを採用します。

実装アプローチの比較

アプローチメリットデメリット適用場面
OT実装が比較的簡単複雑な操作で競合が発生テキストエディタ
CRDT競合が発生しない実装が複雑分散システム
ロック実装が最も簡単同時編集ができない単純な編集機能

基本的な Zustand ストアの構築

まず、コラボレーション機能の基盤となる Zustand ストアを構築します。

プロジェクトのセットアップ

bash# プロジェクトの初期化
yarn create next-app collaboration-editor --typescript
cd collaboration-editor

# 必要な依存関係のインストール
yarn add zustand socket.io-client
yarn add -D @types/socket.io-client

基本的なストアの実装

typescript// store/documentStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface Operation {
  id: string;
  type: 'insert' | 'delete';
  position: number;
  content?: string;
  userId: string;
  timestamp: number;
}

interface DocumentState {
  content: string;
  operations: Operation[];
  cursorPosition: number;
  isEditing: boolean;
}

interface DocumentActions {
  insertText: (position: number, text: string) => void;
  deleteText: (position: number, length: number) => void;
  setCursorPosition: (position: number) => void;
  setEditing: (editing: boolean) => void;
  applyOperation: (operation: Operation) => void;
}

type DocumentStore = DocumentState & DocumentActions;

export const useDocumentStore = create<DocumentStore>()(
  subscribeWithSelector((set, get) => ({
    content: '',
    operations: [],
    cursorPosition: 0,
    isEditing: false,

    insertText: (position, text) => {
      const operation: Operation = {
        id: generateId(),
        type: 'insert',
        position,
        content: text,
        userId: 'current-user',
        timestamp: Date.now(),
      };

      set((state) => ({
        content:
          state.content.slice(0, position) +
          text +
          state.content.slice(position),
        operations: [...state.operations, operation],
        cursorPosition: position + text.length,
      }));

      // 他のユーザーに操作を送信
      emitOperation(operation);
    },

    deleteText: (position, length) => {
      const operation: Operation = {
        id: generateId(),
        type: 'delete',
        position,
        content: get().content.slice(
          position,
          position + length
        ),
        userId: 'current-user',
        timestamp: Date.now(),
      };

      set((state) => ({
        content:
          state.content.slice(0, position) +
          state.content.slice(position + length),
        operations: [...state.operations, operation],
        cursorPosition: position,
      }));

      emitOperation(operation);
    },

    setCursorPosition: (position) =>
      set({ cursorPosition: position }),
    setEditing: (editing) => set({ isEditing: editing }),

    applyOperation: (operation) => {
      set((state) => {
        let newContent = state.content;

        if (operation.type === 'insert') {
          newContent =
            state.content.slice(0, operation.position) +
            (operation.content || '') +
            state.content.slice(operation.position);
        } else if (operation.type === 'delete') {
          newContent =
            state.content.slice(0, operation.position) +
            state.content.slice(
              operation.position +
                (operation.content?.length || 0)
            );
        }

        return {
          content: newContent,
          operations: [...state.operations, operation],
        };
      });
    },
  }))
);

// ユーティリティ関数
function generateId(): string {
  return Math.random().toString(36).substr(2, 9);
}

function emitOperation(operation: Operation) {
  // WebSocketを通じて操作を送信
  // 実装は後述
}

このストアは、テキストの挿入と削除の操作を管理し、各操作にタイムスタンプとユーザー ID を付与します。

エラーハンドリングの追加

typescript// store/errorStore.ts
import { create } from 'zustand';

interface ErrorState {
  errors: Array<{
    id: string;
    message: string;
    type: 'network' | 'validation' | 'sync';
    timestamp: Date;
  }>;
}

interface ErrorActions {
  addError: (
    message: string,
    type: 'network' | 'validation' | 'sync'
  ) => void;
  removeError: (errorId: string) => void;
  clearErrors: () => void;
}

type ErrorStore = ErrorState & ErrorActions;

export const useErrorStore = create<ErrorStore>((set) => ({
  errors: [],

  addError: (message, type) =>
    set((state) => ({
      errors: [
        ...state.errors,
        {
          id: Math.random().toString(36).substr(2, 9),
          message,
          type,
          timestamp: new Date(),
        },
      ],
    })),

  removeError: (errorId) =>
    set((state) => ({
      errors: state.errors.filter(
        (error) => error.id !== errorId
      ),
    })),

  clearErrors: () => set({ errors: [] }),
}));

リアルタイム同期機能の実装

リアルタイム同期を実現するために、WebSocket を使用した通信機能を実装します。

WebSocket 接続の管理

typescript// services/websocketService.ts
import { io, Socket } from 'socket.io-client';
import { useDocumentStore } from '../store/documentStore';
import { useErrorStore } from '../store/errorStore';

class WebSocketService {
  private socket: Socket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;

  connect(url: string, userId: string) {
    try {
      this.socket = io(url, {
        auth: { userId },
        transports: ['websocket', 'polling'],
        timeout: 20000,
      });

      this.setupEventListeners();
    } catch (error) {
      useErrorStore
        .getState()
        .addError(
          `WebSocket接続エラー: ${
            error instanceof Error
              ? error.message
              : 'Unknown error'
          }`,
          'network'
        );
    }
  }

  private setupEventListeners() {
    if (!this.socket) return;

    this.socket.on('connect', () => {
      console.log('WebSocket接続確立');
      this.reconnectAttempts = 0;
    });

    this.socket.on('disconnect', (reason) => {
      console.log('WebSocket接続切断:', reason);
      useErrorStore
        .getState()
        .addError(
          `接続が切断されました: ${reason}`,
          'network'
        );
    });

    this.socket.on('operation', (operation) => {
      // 他のユーザーからの操作を受信
      useDocumentStore.getState().applyOperation(operation);
    });

    this.socket.on('user_joined', (user) => {
      console.log('ユーザー参加:', user);
    });

    this.socket.on('user_left', (userId) => {
      console.log('ユーザー退出:', userId);
    });

    this.socket.on('connect_error', (error) => {
      console.error('接続エラー:', error);
      this.handleReconnect();
    });
  }

  private handleReconnect() {
    if (
      this.reconnectAttempts < this.maxReconnectAttempts
    ) {
      this.reconnectAttempts++;
      setTimeout(() => {
        console.log(
          `再接続試行 ${this.reconnectAttempts}/${this.maxReconnectAttempts}`
        );
        this.socket?.connect();
      }, 1000 * Math.pow(2, this.reconnectAttempts)); // 指数バックオフ
    } else {
      useErrorStore
        .getState()
        .addError(
          '最大再接続試行回数に達しました。ページを再読み込みしてください。',
          'network'
        );
    }
  }

  emitOperation(operation: any) {
    if (this.socket?.connected) {
      this.socket.emit('operation', operation);
    } else {
      useErrorStore
        .getState()
        .addError(
          'WebSocket接続が確立されていません。操作を送信できません。',
          'network'
        );
    }
  }

  disconnect() {
    this.socket?.disconnect();
    this.socket = null;
  }
}

export const websocketService = new WebSocketService();

サーバーサイドの実装

typescript// server/index.ts
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
});

app.use(cors());
app.use(express.json());

// 接続中のユーザーを管理
const connectedUsers = new Map<string, any>();

io.on('connection', (socket) => {
  const userId = socket.handshake.auth.userId;

  console.log(`ユーザー接続: ${userId}`);
  connectedUsers.set(userId, {
    id: userId,
    socketId: socket.id,
    joinedAt: new Date(),
  });

  // 他のユーザーに参加を通知
  socket.broadcast.emit('user_joined', {
    id: userId,
    joinedAt: new Date(),
  });

  // 操作の受信と配信
  socket.on('operation', (operation) => {
    console.log(
      `操作受信: ${operation.type} from ${operation.userId}`
    );

    // 他のユーザーに操作を配信
    socket.broadcast.emit('operation', operation);
  });

  // 切断処理
  socket.on('disconnect', () => {
    console.log(`ユーザー切断: ${userId}`);
    connectedUsers.delete(userId);

    // 他のユーザーに退出を通知
    socket.broadcast.emit('user_left', userId);
  });

  // エラーハンドリング
  socket.on('error', (error) => {
    console.error('Socket error:', error);
  });
});

const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
  console.log(`サーバー起動: http://localhost:${PORT}`);
});

クライアントでの WebSocket 統合

typescript// hooks/useWebSocket.ts
import { useEffect, useRef } from 'react';
import { websocketService } from '../services/websocketService';
import { useDocumentStore } from '../store/documentStore';

export const useWebSocket = (userId: string) => {
  const isConnected = useRef(false);

  useEffect(() => {
    if (!isConnected.current) {
      websocketService.connect(
        'http://localhost:3001',
        userId
      );
      isConnected.current = true;
    }

    return () => {
      websocketService.disconnect();
      isConnected.current = false;
    };
  }, [userId]);

  // 操作の送信を監視
  useEffect(() => {
    const unsubscribe = useDocumentStore.subscribe(
      (state) => state.operations,
      (operations) => {
        if (operations.length > 0) {
          const lastOperation =
            operations[operations.length - 1];
          if (lastOperation.userId === userId) {
            websocketService.emitOperation(lastOperation);
          }
        }
      }
    );

    return unsubscribe;
  }, [userId]);
};

競合解決メカニズムの設計

複数のユーザーが同時に編集する際の競合を解決するために、Operational Transformation(OT)アルゴリズムを実装します。

OT アルゴリズムの実装

typescript// utils/operationalTransform.ts
interface Operation {
  id: string;
  type: 'insert' | 'delete';
  position: number;
  content?: string;
  userId: string;
  timestamp: number;
}

export class OperationalTransform {
  // 操作を変換して競合を解決
  static transform(
    op1: Operation,
    op2: Operation
  ): [Operation, Operation] {
    if (op1.type === 'insert' && op2.type === 'insert') {
      return this.transformInsertInsert(op1, op2);
    } else if (
      op1.type === 'delete' &&
      op2.type === 'delete'
    ) {
      return this.transformDeleteDelete(op1, op2);
    } else if (
      op1.type === 'insert' &&
      op2.type === 'delete'
    ) {
      return this.transformInsertDelete(op1, op2);
    } else {
      return this.transformDeleteInsert(op1, op2);
    }
  }

  private static transformInsertInsert(
    op1: Operation,
    op2: Operation
  ): [Operation, Operation] {
    if (op1.position < op2.position) {
      return [
        op1,
        {
          ...op2,
          position:
            op2.position + (op1.content?.length || 0),
        },
      ];
    } else if (op1.position > op2.position) {
      return [
        {
          ...op1,
          position:
            op1.position + (op2.content?.length || 0),
        },
        op2,
      ];
    } else {
      // 同じ位置の場合はタイムスタンプで決定
      if (op1.timestamp < op2.timestamp) {
        return [
          op1,
          {
            ...op2,
            position:
              op2.position + (op1.content?.length || 0),
          },
        ];
      } else {
        return [
          {
            ...op1,
            position:
              op1.position + (op2.content?.length || 0),
          },
          op2,
        ];
      }
    }
  }

  private static transformDeleteDelete(
    op1: Operation,
    op2: Operation
  ): [Operation, Operation] {
    const op1End =
      op1.position + (op1.content?.length || 0);
    const op2End =
      op2.position + (op2.content?.length || 0);

    if (op1End <= op2.position) {
      return [op1, op2];
    } else if (op2End <= op1.position) {
      return [op1, op2];
    } else {
      // 重複部分がある場合
      const overlapStart = Math.max(
        op1.position,
        op2.position
      );
      const overlapEnd = Math.min(op1End, op2End);
      const overlapLength = overlapEnd - overlapStart;

      const newOp1 = {
        ...op1,
        content:
          op1.content?.slice(
            0,
            overlapStart - op1.position
          ) + op1.content?.slice(overlapEnd - op1.position),
      };

      const newOp2 = {
        ...op2,
        content:
          op2.content?.slice(
            0,
            overlapStart - op2.position
          ) + op2.content?.slice(overlapEnd - op2.position),
      };

      return [newOp1, newOp2];
    }
  }

  private static transformInsertDelete(
    op1: Operation,
    op2: Operation
  ): [Operation, Operation] {
    if (op1.position <= op2.position) {
      return [
        op1,
        {
          ...op2,
          position:
            op2.position + (op1.content?.length || 0),
        },
      ];
    } else {
      const op2End =
        op2.position + (op2.content?.length || 0);
      if (op1.position >= op2End) {
        return [
          {
            ...op1,
            position:
              op1.position - (op2.content?.length || 0),
          },
          op2,
        ];
      } else {
        // 挿入位置が削除範囲内の場合
        return [{ ...op1, position: op2.position }, op2];
      }
    }
  }

  private static transformDeleteInsert(
    op1: Operation,
    op2: Operation
  ): [Operation, Operation] {
    return this.transformInsertDelete(
      op2,
      op1
    ).reverse() as [Operation, Operation];
  }
}

競合解決の統合

typescript// store/collaborationStore.ts
import { OperationalTransform } from '../utils/operationalTransform';

// 既存のストアに競合解決機能を追加
interface CollaborationStore {
  // ... 既存のプロパティ
  pendingOperations: Operation[];
  resolvedOperations: Operation[];

  addPendingOperation: (operation: Operation) => void;
  resolveConflicts: () => void;
  applyResolvedOperation: (operation: Operation) => void;
}

export const useCollaborationStore =
  create<CollaborationStore>()(
    devtools(
      (set, get) => ({
        // ... 既存のプロパティとメソッド
        pendingOperations: [],
        resolvedOperations: [],

        addPendingOperation: (operation) => {
          set((state) => ({
            pendingOperations: [
              ...state.pendingOperations,
              operation,
            ],
          }));

          // 競合解決を実行
          get().resolveConflicts();
        },

        resolveConflicts: () => {
          const { pendingOperations, resolvedOperations } =
            get();

          if (pendingOperations.length === 0) return;

          const newOperations = [...pendingOperations];
          const resolved = [...resolvedOperations];

          // 操作をタイムスタンプ順にソート
          newOperations.sort(
            (a, b) => a.timestamp - b.timestamp
          );

          for (let i = 0; i < newOperations.length; i++) {
            let currentOp = newOperations[i];

            // 既存の解決済み操作との競合を解決
            for (const resolvedOp of resolved) {
              const [
                transformedCurrent,
                transformedResolved,
              ] = OperationalTransform.transform(
                currentOp,
                resolvedOp
              );

              currentOp = transformedCurrent;
            }

            resolved.push(currentOp);
          }

          set({
            pendingOperations: [],
            resolvedOperations: resolved,
          });

          // 解決済み操作を適用
          resolved.forEach((op) =>
            get().applyResolvedOperation(op)
          );
        },

        applyResolvedOperation: (operation) => {
          // ドキュメントストアに操作を適用
          useDocumentStore
            .getState()
            .applyOperation(operation);
        },
      }),
      { name: 'collaboration-store' }
    )
  );

ユーザー管理とセッション制御

複数ユーザーの同時編集を管理するために、ユーザー管理とセッション制御機能を実装します。

ユーザー管理ストア

typescript// store/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  name: string;
  color: string;
  avatar?: string;
  isOnline: boolean;
  lastSeen: Date;
  cursorPosition?: { x: number; y: number };
  selection?: { start: number; end: number };
}

interface UserState {
  currentUser: User | null;
  onlineUsers: User[];
  userPreferences: {
    theme: 'light' | 'dark';
    fontSize: number;
    autoSave: boolean;
  };
}

interface UserActions {
  setCurrentUser: (user: User) => void;
  updateOnlineUsers: (users: User[]) => void;
  updateUserCursor: (
    userId: string,
    position: { x: number; y: number }
  ) => void;
  updateUserSelection: (
    userId: string,
    selection: { start: number; end: number }
  ) => void;
  setUserPreferences: (
    preferences: Partial<UserState['userPreferences']>
  ) => void;
  logout: () => void;
}

type UserStore = UserState & UserActions;

export const useUserStore = create<UserStore>()(
  persist(
    (set, get) => ({
      currentUser: null,
      onlineUsers: [],
      userPreferences: {
        theme: 'light',
        fontSize: 14,
        autoSave: true,
      },

      setCurrentUser: (user) => set({ currentUser: user }),

      updateOnlineUsers: (users) =>
        set({ onlineUsers: users }),

      updateUserCursor: (userId, position) =>
        set((state) => ({
          onlineUsers: state.onlineUsers.map((user) =>
            user.id === userId
              ? { ...user, cursorPosition: position }
              : user
          ),
        })),

      updateUserSelection: (userId, selection) =>
        set((state) => ({
          onlineUsers: state.onlineUsers.map((user) =>
            user.id === userId
              ? { ...user, selection }
              : user
          ),
        })),

      setUserPreferences: (preferences) =>
        set((state) => ({
          userPreferences: {
            ...state.userPreferences,
            ...preferences,
          },
        })),

      logout: () =>
        set({ currentUser: null, onlineUsers: [] }),
    }),
    {
      name: 'user-storage',
      partialize: (state) => ({
        currentUser: state.currentUser,
        userPreferences: state.userPreferences,
      }),
    }
  )
);

セッション管理

typescript// services/sessionService.ts
import { useUserStore } from '../store/userStore';
import { useErrorStore } from '../store/errorStore';

class SessionService {
  private sessionId: string | null = null;
  private heartbeatInterval: NodeJS.Timeout | null = null;

  createSession(userId: string): string {
    this.sessionId = `${userId}-${Date.now()}-${Math.random()
      .toString(36)
      .substr(2, 9)}`;

    // セッション情報をローカルストレージに保存
    localStorage.setItem('sessionId', this.sessionId);
    localStorage.setItem('userId', userId);

    // ハートビートを開始
    this.startHeartbeat();

    return this.sessionId;
  }

  getSessionId(): string | null {
    if (!this.sessionId) {
      this.sessionId = localStorage.getItem('sessionId');
    }
    return this.sessionId;
  }

  private startHeartbeat() {
    this.heartbeatInterval = setInterval(() => {
      this.sendHeartbeat();
    }, 30000); // 30秒ごと
  }

  private sendHeartbeat() {
    const sessionId = this.getSessionId();
    if (sessionId) {
      // サーバーにハートビートを送信
      fetch('/api/heartbeat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId }),
      }).catch((error) => {
        useErrorStore
          .getState()
          .addError(
            'セッションの維持に失敗しました。再接続を試行します。',
            'network'
          );
      });
    }
  }

  endSession() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
      this.heartbeatInterval = null;
    }

    const sessionId = this.getSessionId();
    if (sessionId) {
      // サーバーにセッション終了を通知
      fetch('/api/session/end', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId }),
      }).catch(() => {
        // エラーは無視(既に切断されている可能性があるため)
      });
    }

    // ローカルストレージをクリア
    localStorage.removeItem('sessionId');
    localStorage.removeItem('userId');

    this.sessionId = null;
  }

  isSessionValid(): boolean {
    const sessionId = this.getSessionId();
    const userId = localStorage.getItem('userId');

    return !!(sessionId && userId);
  }
}

export const sessionService = new SessionService();

パフォーマンス最適化

多数のユーザーが同時に編集する場合のパフォーマンスを最適化します。

デバウンス機能の実装

typescript// utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: NodeJS.Timeout | null = null;

  return (...args: Parameters<T>) => {
    if (timeout) {
      clearTimeout(timeout);
    }

    timeout = setTimeout(() => {
      func(...args);
    }, wait);
  };
}

// ストアでのデバウンス使用例
const debouncedUpdateDocument = debounce(
  (content: string) => {
    useDocumentStore.getState().updateDocument(content);
  },
  300
); // 300msのデバウンス

操作のバッチ処理

typescript// store/optimizedDocumentStore.ts
import { create } from 'zustand';
import { debounce } from '../utils/debounce';

interface OptimizedDocumentStore {
  content: string;
  pendingOperations: Operation[];
  batchSize: number;

  addOperation: (operation: Operation) => void;
  flushOperations: () => void;
  setBatchSize: (size: number) => void;
}

export const useOptimizedDocumentStore =
  create<OptimizedDocumentStore>()((set, get) => ({
    content: '',
    pendingOperations: [],
    batchSize: 10,

    addOperation: (operation) => {
      set((state) => ({
        pendingOperations: [
          ...state.pendingOperations,
          operation,
        ],
      }));

      // バッチサイズに達したら処理
      if (
        get().pendingOperations.length >= get().batchSize
      ) {
        get().flushOperations();
      }
    },

    flushOperations: debounce(() => {
      const { pendingOperations } = get();
      if (pendingOperations.length === 0) return;

      // 操作をバッチで処理
      let newContent = get().content;

      pendingOperations.forEach((op) => {
        if (op.type === 'insert') {
          newContent =
            newContent.slice(0, op.position) +
            (op.content || '') +
            newContent.slice(op.position);
        } else if (op.type === 'delete') {
          newContent =
            newContent.slice(0, op.position) +
            newContent.slice(
              op.position + (op.content?.length || 0)
            );
        }
      });

      set({
        content: newContent,
        pendingOperations: [],
      });
    }, 100),

    setBatchSize: (size) => set({ batchSize: size }),
  }));

メモリ使用量の最適化

typescript// utils/memoryOptimizer.ts
export class MemoryOptimizer {
  private static readonly MAX_OPERATIONS = 1000;
  private static readonly CLEANUP_THRESHOLD = 800;

  static cleanupOperations(
    operations: Operation[]
  ): Operation[] {
    if (operations.length <= this.MAX_OPERATIONS) {
      return operations;
    }

    // 古い操作を削除してメモリ使用量を削減
    const sortedOperations = operations.sort(
      (a, b) => a.timestamp - b.timestamp
    );
    const recentOperations = sortedOperations.slice(
      -this.CLEANUP_THRESHOLD
    );

    console.log(
      `操作履歴をクリーンアップ: ${operations.length} -> ${recentOperations.length}`
    );

    return recentOperations;
  }

  static compressOperations(
    operations: Operation[]
  ): Operation[] {
    // 連続する同じタイプの操作を圧縮
    const compressed: Operation[] = [];

    for (let i = 0; i < operations.length; i++) {
      const current = operations[i];
      const next = operations[i + 1];

      if (
        next &&
        current.type === next.type &&
        current.userId === next.userId &&
        current.type === 'insert' &&
        current.position +
          (current.content?.length || 0) ===
          next.position
      ) {
        // 連続する挿入操作を結合
        compressed.push({
          ...current,
          content:
            (current.content || '') + (next.content || ''),
        });
        i++; // 次の操作をスキップ
      } else {
        compressed.push(current);
      }
    }

    return compressed;
  }
}

エラーハンドリングとフォールバック

実際の運用で発生する可能性のあるエラーとその対処法を実装します。

エラーの種類と対処法

typescript// types/errors.ts
export enum ErrorType {
  NETWORK_ERROR = 'NETWORK_ERROR',
  VALIDATION_ERROR = 'VALIDATION_ERROR',
  SYNCHRONIZATION_ERROR = 'SYNCHRONIZATION_ERROR',
  PERMISSION_ERROR = 'PERMISSION_ERROR',
  TIMEOUT_ERROR = 'TIMEOUT_ERROR',
}

export interface AppError {
  type: ErrorType;
  message: string;
  code?: string;
  details?: any;
  timestamp: Date;
  userId?: string;
}

// エラーハンドリングサービス
export class ErrorHandler {
  static handle(error: AppError) {
    console.error('エラーが発生しました:', error);

    switch (error.type) {
      case ErrorType.NETWORK_ERROR:
        this.handleNetworkError(error);
        break;
      case ErrorType.SYNCHRONIZATION_ERROR:
        this.handleSyncError(error);
        break;
      case ErrorType.VALIDATION_ERROR:
        this.handleValidationError(error);
        break;
      default:
        this.handleGenericError(error);
    }
  }

  private static handleNetworkError(error: AppError) {
    useErrorStore
      .getState()
      .addError(
        `ネットワークエラー: ${error.message}`,
        'network'
      );

    // 自動再接続を試行
    setTimeout(() => {
      websocketService.connect(
        'http://localhost:3001',
        'current-user'
      );
    }, 5000);
  }

  private static handleSyncError(error: AppError) {
    useErrorStore
      .getState()
      .addError(`同期エラー: ${error.message}`, 'sync');

    // ローカル状態をバックアップ
    this.backupLocalState();
  }

  private static handleValidationError(error: AppError) {
    useErrorStore
      .getState()
      .addError(
        `検証エラー: ${error.message}`,
        'validation'
      );
  }

  private static handleGenericError(error: AppError) {
    useErrorStore
      .getState()
      .addError(
        `予期しないエラー: ${error.message}`,
        'network'
      );
  }

  private static backupLocalState() {
    const documentState = useDocumentStore.getState();
    const backup = {
      content: documentState.content,
      operations: documentState.operations,
      timestamp: new Date(),
    };

    localStorage.setItem(
      'documentBackup',
      JSON.stringify(backup)
    );
  }
}

フォールバック機能の実装

typescript// services/fallbackService.ts
export class FallbackService {
  private static readonly MAX_RETRY_ATTEMPTS = 3;
  private static readonly RETRY_DELAY = 1000;

  static async withFallback<T>(
    operation: () => Promise<T>,
    fallback: () => T,
    retryAttempts: number = this.MAX_RETRY_ATTEMPTS
  ): Promise<T> {
    try {
      return await operation();
    } catch (error) {
      console.warn(
        '操作が失敗しました。フォールバックを実行します:',
        error
      );

      if (retryAttempts > 0) {
        await this.delay(this.RETRY_DELAY);
        return this.withFallback(
          operation,
          fallback,
          retryAttempts - 1
        );
      }

      return fallback();
    }
  }

  static async syncWithFallback() {
    return this.withFallback(
      async () => {
        // 通常の同期処理
        const response = await fetch('/api/sync', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            content: useDocumentStore.getState().content,
            operations:
              useDocumentStore.getState().operations,
          }),
        });

        if (!response.ok) {
          throw new Error(`同期失敗: ${response.status}`);
        }

        return await response.json();
      },
      () => {
        // フォールバック: ローカルバックアップから復元
        const backup = localStorage.getItem(
          'documentBackup'
        );
        if (backup) {
          const parsed = JSON.parse(backup);
          useDocumentStore.setState({
            content: parsed.content,
            operations: parsed.operations,
          });
        }

        return {
          success: false,
          message: 'ローカルバックアップから復元しました',
        };
      }
    );
  }

  private static delay(ms: number): Promise<void> {
    return new Promise((resolve) =>
      setTimeout(resolve, ms)
    );
  }
}

実際のエラーコード例

typescript// よく発生するエラーとその対処法
const commonErrors = {
  // WebSocket接続エラー
  ECONNREFUSED: {
    message:
      'サーバーに接続できません。サーバーが起動しているか確認してください。',
    solution:
      'サーバーを起動するか、接続設定を確認してください。',
  },

  // タイムアウトエラー
  ETIMEDOUT: {
    message: '接続がタイムアウトしました。',
    solution:
      'ネットワーク接続を確認し、再試行してください。',
  },

  // 認証エラー
  UNAUTHORIZED: {
    message: '認証に失敗しました。',
    solution: 'ログインし直してください。',
  },

  // 同期競合エラー
  SYNC_CONFLICT: {
    message: 'データの同期で競合が発生しました。',
    solution:
      'ページを再読み込みして最新の状態を取得してください。',
  },
};

// エラーハンドリングの実装例
function handleSpecificError(errorCode: string) {
  const error =
    commonErrors[errorCode as keyof typeof commonErrors];

  if (error) {
    useErrorStore
      .getState()
      .addError(
        `${error.message} ${error.solution}`,
        'network'
      );
  } else {
    useErrorStore
      .getState()
      .addError(
        `予期しないエラーが発生しました: ${errorCode}`,
        'network'
      );
  }
}

まとめ

この記事では、Zustand を使用して複数ユーザーが同時編集できるコラボレーション機能を実装する方法をご紹介しました。

実装のポイント

  1. Zustand の活用: 軽量で使いやすい状態管理ライブラリを活用して、複雑な状態を効率的に管理
  2. Operational Transformation: 競合解決のための OT アルゴリズムを実装し、データの整合性を保証
  3. リアルタイム通信: WebSocket を使用したリアルタイム同期機能の実装
  4. エラーハンドリング: 実際の運用で発生する可能性のあるエラーに対する包括的な対処法
  5. パフォーマンス最適化: デバウンスやバッチ処理によるパフォーマンスの向上

実装の利点

  • 学習コストが低い: Zustand のシンプルな API により、複雑な機能でも理解しやすい
  • 拡張性が高い: モジュラー設計により、機能の追加や変更が容易
  • 型安全性: TypeScript との相性が良く、開発時のエラーを防げる
  • パフォーマンス: 軽量なライブラリと最適化により、高速な動作を実現

今後の発展

この実装を基盤として、以下のような機能を追加することができます:

  • ファイルアップロード機能: 画像やドキュメントの共有
  • コメント機能: 特定の部分に対するコメントやフィードバック
  • バージョン管理: 編集履歴の管理と復元機能
  • 権限管理: 編集権限の細かい制御

Zustand の柔軟性とシンプルさを活かすことで、複雑なコラボレーション機能でも保守性の高いコードを書くことができます。実際のプロジェクトでこの実装を参考にしていただき、チーム開発の効率化にお役立てください。

関連リンク