T-CREATOR

オフラインファースト設計:Zustand で楽観的 UI とロールバックを実現

オフラインファースト設計:Zustand で楽観的 UI とロールバックを実現

現代の Web アプリケーションでは、ネットワーク環境に左右されない快適なユーザー体験が求められています。オフラインファースト設計は、接続が不安定な環境でも操作を継続でき、サーバーとの同期を後から行える仕組みです。

Zustand を使えば、楽観的 UI 更新とロールバック機能を軽量かつシンプルに実装できます。本記事では、オフラインファースト設計の基本から、Zustand による実践的な実装方法まで、初心者にもわかりやすく解説します。

背景

オフラインファースト設計とは

オフラインファースト設計は、ネットワーク接続の有無に関わらず、ユーザーが操作を継続できるアーキテクチャです。従来のオンライン前提の設計では、通信エラー時にユーザーの操作がブロックされてしまいます。

一方、オフラインファーストでは、ユーザーの操作を即座にローカルで反映し、バックグラウンドでサーバーと同期します。これにより、地下鉄やエレベーター内など、接続が途切れがちな環境でも快適に利用できるのです。

オフラインファースト設計のアーキテクチャ全体像を確認しましょう。

mermaidflowchart TB
  user["ユーザー操作"]
  localStore["ローカルストア<br/>(Zustand)"]
  optimistic["楽観的UI更新"]
  queue["同期キュー"]
  sync["バックグラウンド同期"]
  server["サーバーAPI"]
  success["同期成功"]
  rollback["ロールバック"]

  user -->|即座に反映| localStore
  localStore --> optimistic
  localStore --> queue
  queue --> sync
  sync -->|リクエスト| server
  server -->|成功| success
  server -->|失敗| rollback
  success --> localStore
  rollback --> localStore

この図が示すように、ユーザー操作は即座にローカルストアに反映され、バックグラウンドでサーバーと同期されます。同期に失敗した場合は、ロールバック処理により元の状態に戻ります。

Zustand の特徴

Zustand は、React のための軽量な状態管理ライブラリです。Redux と比較して設定がシンプルで、ボイラープレートコードが少ないのが特徴です。

#項目ZustandReduxContext API
1学習コスト★★☆☆☆★★★★★★★★☆☆
2ボイラープレート少ない多い中程度
3パフォーマンス高速高速低速
4バンドルサイズ1KB10KB0KB(組み込み)
5ミドルウェア対応×

Zustand はミドルウェア機能を持ち、永続化や DevTools との連携が容易です。オフラインファースト設計では、この柔軟性が大きな強みになります。

楽観的 UI とは

楽観的 UI(Optimistic UI)は、サーバーへのリクエストが成功すると「楽観的に」想定し、レスポンスを待たずに UI を更新する手法です。

通常の UI 更新では、サーバーからの応答を待ってから画面を更新するため、ネットワーク遅延が体感速度に直結します。楽観的 UI では、操作と同時に UI を更新するため、ユーザーは即座にフィードバックを得られます。

楽観的 UI と従来の UI 更新の違いを見てみましょう。

mermaidsequenceDiagram
  participant U as ユーザー
  participant UI as UI
  participant S as サーバー

  Note over U,S: 従来の UI 更新
  U->>UI: ボタンクリック
  UI->>S: API リクエスト
  Note over UI: ローディング表示
  S-->>UI: レスポンス(500ms後)
  UI->>U: UI 更新

  Note over U,S: 楽観的 UI
  U->>UI: ボタンクリック
  UI->>U: 即座に UI 更新
  UI->>S: API リクエスト(バックグラウンド)
  S-->>UI: レスポンス(500ms後)
  Note over UI: 成功時:何もしない<br/>失敗時:ロールバック

この図が示すように、楽観的 UI では操作直後に UI が更新されるため、ユーザーは待ち時間なく次の操作に移れます。

課題

オフラインファースト設計の技術的課題

オフラインファースト設計を実装する際には、いくつかの技術的な課題に直面します。

まず、ローカルとサーバーの状態を同期する仕組みが必要です。どの操作をいつ同期するのか、同期の優先順位をどう決めるのかを設計しなければなりません。

次に、同期の失敗時に適切にロールバックする処理が求められます。ユーザーに失敗を通知しつつ、UI を元の状態に戻す必要があります。

さらに、複数の操作が同時に発生した場合の競合解決も考慮すべきです。オフライン中に複数の変更があった場合、どの順序で同期するかが重要になります。

オフラインファースト設計における状態遷移を整理しましょう。

mermaidstateDiagram-v2
  [*] --> Idle: 初期状態
  Idle --> Updating: ユーザー操作
  Updating --> Pending: ローカル更新完了
  Pending --> Syncing: 同期開始
  Syncing --> Success: API成功
  Syncing --> Failed: API失敗
  Success --> Idle: 確定
  Failed --> RollingBack: ロールバック開始
  RollingBack --> Idle: 元に戻す
  Pending --> Queued: オフライン検知
  Queued --> Syncing: オンライン復帰

この状態遷移図が示すように、各操作は複数の状態を経て最終的に確定または巻き戻されます。

従来の状態管理ライブラリでの実装の難しさ

Redux などの従来の状態管理ライブラリでオフラインファーストを実装する場合、多くのボイラープレートコードが必要になります。

アクションタイプ、アクションクリエーター、リデューサー、ミドルウェアをそれぞれ定義し、さらに楽観的更新のための一時的な状態管理も必要です。

Context API を使う場合は、パフォーマンス問題に直面します。状態の一部が更新されるたびに、その Context を参照するすべてのコンポーネントが再レンダリングされるためです。

#課題ReduxContext APIZustand
1コード量多い(200 行以上)中程度(100 行程度)少ない(50 行程度)
2学習コスト高い中程度低い
3再レンダリング制御セレクター必要困難自動最適化
4ロールバック実装複雑複雑シンプル
5型安全性要手動定義要手動定義自動推論

Zustand は、これらの課題を最小限のコードで解決できる設計になっています。

エラーハンドリングとユーザー体験

楽観的 UI では、同期失敗時のエラーハンドリングが特に重要です。ユーザーは操作が成功したと思っているため、失敗を適切に伝えないと混乱を招きます。

エラーメッセージは具体的で、ユーザーが次に何をすべきかを示す必要があります。単に「エラーが発生しました」では不十分で、「保存に失敗しました。再試行しますか?」のような明確な選択肢を提示すべきです。

また、ロールバック時には、どの操作が取り消されたのかを視覚的に分かりやすく示すことが求められます。

解決策

Zustand による楽観的 UI の基本パターン

Zustand を使った楽観的 UI の実装は、シンプルなパターンに従います。まず、操作前の状態をスナップショットとして保存し、即座に UI を更新します。その後、バックグラウンドで API を呼び出し、成功すれば確定、失敗すればスナップショットから復元します。

基本的なストアの構造を定義しましょう。

typescript// types.ts - 型定義

// タスクの基本型
interface Task {
  id: string;
  title: string;
  completed: boolean;
  createdAt: number;
  updatedAt: number;
}

// 同期状態を表す型
type SyncStatus =
  | 'idle'
  | 'pending'
  | 'syncing'
  | 'success'
  | 'error';

// 操作履歴のスナップショット
interface Snapshot {
  tasks: Task[];
  timestamp: number;
}

次に、ストアの状態とアクションを定義します。

typescript// store/types.ts - ストアの型定義

interface TaskStore {
  // 状態
  tasks: Task[];
  syncStatus: SyncStatus;
  error: string | null;
  snapshots: Map<string, Snapshot>;

  // アクション
  addTask: (title: string) => Promise<void>;
  updateTask: (
    id: string,
    updates: Partial<Task>
  ) => Promise<void>;
  deleteTask: (id: string) => Promise<void>;
  rollback: (operationId: string) => void;
}

ストアの構造を定義したので、次は実装に進みます。

スナップショットとロールバックの実装

スナップショット機能は、操作前の状態を一時的に保存する仕組みです。操作ごとに一意の ID を生成し、その ID をキーとしてスナップショットを保存します。

スナップショットの作成と復元のヘルパー関数を実装しましょう。

typescript// store/helpers.ts - スナップショット管理

import { Task, Snapshot } from '../types';

// 操作IDを生成
export const generateOperationId = (): string => {
  return `op_${Date.now()}_${Math.random()
    .toString(36)
    .substr(2, 9)}`;
};

// スナップショットを作成
export const createSnapshot = (tasks: Task[]): Snapshot => {
  return {
    tasks: JSON.parse(JSON.stringify(tasks)), // ディープコピー
    timestamp: Date.now(),
  };
};

スナップショットからの復元処理を実装します。

typescript// store/helpers.ts - 復元処理(続き)

// スナップショットから復元
export const restoreFromSnapshot = (
  snapshot: Snapshot,
  currentTasks: Task[]
): Task[] => {
  // スナップショット時点のタスクリストを返す
  return snapshot.tasks;
};

// 古いスナップショットをクリーンアップ(5分以上前のものを削除)
export const cleanupSnapshots = (
  snapshots: Map<string, Snapshot>
): Map<string, Snapshot> => {
  const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
  const cleaned = new Map(snapshots);

  for (const [id, snapshot] of cleaned.entries()) {
    if (snapshot.timestamp < fiveMinutesAgo) {
      cleaned.delete(id);
    }
  }

  return cleaned;
};

これらのヘルパー関数により、スナップショットの管理が簡潔になります。

Zustand ストアの実装

それでは、実際に Zustand ストアを実装していきます。楽観的更新とロールバック機能を備えた完全なストアです。

まず、ストアの基本構造を作成します。

typescript// store/taskStore.ts - ストア作成

import { create } from 'zustand';
import { TaskStore, Task, SyncStatus } from './types';
import {
  generateOperationId,
  createSnapshot,
  restoreFromSnapshot,
  cleanupSnapshots,
} from './helpers';
import { api } from '../api';

export const useTaskStore = create<TaskStore>(
  (set, get) => ({
    // 初期状態
    tasks: [],
    syncStatus: 'idle',
    error: null,
    snapshots: new Map(),

    // アクションは次のコードブロックで実装
  })
);

次に、タスク追加のアクションを実装します。楽観的更新のパターンを確認できます。

typescript// store/taskStore.ts - タスク追加アクション

  addTask: async (title: string) => {
    const operationId = generateOperationId();
    const { tasks } = get();

    // 1. スナップショットを作成
    const snapshot = createSnapshot(tasks);

    // 2. 新しいタスクを作成(楽観的更新)
    const newTask: Task = {
      id: `temp_${Date.now()}`, // 一時ID
      title,
      completed: false,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };

    // 3. 即座にUIを更新
    set((state) => ({
      tasks: [...state.tasks, newTask],
      syncStatus: 'pending',
      error: null,
      snapshots: new Map(state.snapshots).set(operationId, snapshot),
    }));

API 呼び出しと結果の処理を実装します。

typescript// store/taskStore.ts - タスク追加の同期処理(続き)

    try {
      // 4. バックグラウンドでAPIを呼び出し
      set({ syncStatus: 'syncing' });
      const savedTask = await api.createTask(title);

      // 5. 成功:一時IDを実際のIDに置き換え
      set((state) => ({
        tasks: state.tasks.map((t) =>
          t.id === newTask.id ? savedTask : t
        ),
        syncStatus: 'success',
      }));

      // スナップショットを削除
      const { snapshots } = get();
      snapshots.delete(operationId);
      set({ snapshots: cleanupSnapshots(snapshots) });

    } catch (error) {
      // 6. 失敗:ロールバック
      get().rollback(operationId);

      set({
        syncStatus: 'error',
        error: error instanceof Error ? error.message : '保存に失敗しました',
      });
    }
  },

タスクの更新と削除のアクションも同様のパターンで実装します。

typescript// store/taskStore.ts - タスク更新アクション

  updateTask: async (id: string, updates: Partial<Task>) => {
    const operationId = generateOperationId();
    const { tasks } = get();
    const snapshot = createSnapshot(tasks);

    // 楽観的更新
    set((state) => ({
      tasks: state.tasks.map((task) =>
        task.id === id
          ? { ...task, ...updates, updatedAt: Date.now() }
          : task
      ),
      syncStatus: 'pending',
      snapshots: new Map(state.snapshots).set(operationId, snapshot),
    }));

    try {
      set({ syncStatus: 'syncing' });
      await api.updateTask(id, updates);
      set({ syncStatus: 'success' });

      const { snapshots } = get();
      snapshots.delete(operationId);
      set({ snapshots: cleanupSnapshots(snapshots) });

    } catch (error) {
      get().rollback(operationId);
      set({
        syncStatus: 'error',
        error: error instanceof Error ? error.message : '更新に失敗しました',
      });
    }
  },

削除アクションを実装します。

typescript// store/taskStore.ts - タスク削除アクション

  deleteTask: async (id: string) => {
    const operationId = generateOperationId();
    const { tasks } = get();
    const snapshot = createSnapshot(tasks);

    // 楽観的削除
    set((state) => ({
      tasks: state.tasks.filter((task) => task.id !== id),
      syncStatus: 'pending',
      snapshots: new Map(state.snapshots).set(operationId, snapshot),
    }));

    try {
      set({ syncStatus: 'syncing' });
      await api.deleteTask(id);
      set({ syncStatus: 'success' });

      const { snapshots } = get();
      snapshots.delete(operationId);
      set({ snapshots: cleanupSnapshots(snapshots) });

    } catch (error) {
      get().rollback(operationId);
      set({
        syncStatus: 'error',
        error: error instanceof Error ? error.message : '削除に失敗しました',
      });
    }
  },

最後に、ロールバック処理を実装します。

typescript// store/taskStore.ts - ロールバック処理

  rollback: (operationId: string) => {
    const { snapshots } = get();
    const snapshot = snapshots.get(operationId);

    if (!snapshot) {
      console.warn(`Snapshot not found for operation: ${operationId}`);
      return;
    }

    // スナップショットから状態を復元
    set({
      tasks: restoreFromSnapshot(snapshot, get().tasks),
      syncStatus: 'idle',
    });

    // スナップショットを削除
    snapshots.delete(operationId);
    set({ snapshots: cleanupSnapshots(snapshots) });
  },
}));

これで、楽観的更新とロールバック機能を備えた Zustand ストアが完成しました。

具体例

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

実装したストアを React コンポーネントで使用する例を見ていきましょう。まず、タスク一覧を表示するコンポーネントです。

typescript// components/TaskList.tsx - タスク一覧コンポーネント

import React from 'react';
import { useTaskStore } from '../store/taskStore';

export const TaskList: React.FC = () => {
  // Zustand のセレクター機能で必要な状態のみ取得
  const tasks = useTaskStore((state) => state.tasks);
  const syncStatus = useTaskStore((state) => state.syncStatus);
  const error = useTaskStore((state) => state.error);

  return (
    <div>
      <h2>タスク一覧</h2>

      {/* 同期状態の表示 */}
      {syncStatus === 'syncing' && (
        <div className="status-badge syncing">同期中...</div>
      )}

      {/* エラー表示 */}
      {error && (
        <div className="error-message">{error}</div>
      )}

タスクリストの表示部分を実装します。

typescript// components/TaskList.tsx - タスク表示(続き)

      {/* タスクリスト */}
      <ul className="task-list">
        {tasks.map((task) => (
          <TaskItem key={task.id} task={task} />
        ))}
      </ul>

      {tasks.length === 0 && (
        <p className="empty-message">タスクがありません</p>
      )}
    </div>
  );
};

個別のタスクアイテムコンポーネントを実装します。

typescript// components/TaskItem.tsx - タスクアイテム

import React from 'react';
import { useTaskStore } from '../store/taskStore';
import { Task } from '../types';

interface TaskItemProps {
  task: Task;
}

export const TaskItem: React.FC<TaskItemProps> = ({ task }) => {
  // アクションを取得
  const updateTask = useTaskStore((state) => state.updateTask);
  const deleteTask = useTaskStore((state) => state.deleteTask);

  // 完了状態のトグル
  const handleToggle = () => {
    updateTask(task.id, { completed: !task.completed });
  };

  // 削除ハンドラー
  const handleDelete = () => {
    if (confirm('本当に削除しますか?')) {
      deleteTask(task.id);
    }
  };

タスクアイテムの表示部分を実装します。

typescript// components/TaskItem.tsx - 表示部分(続き)

  return (
    <li className={`task-item ${task.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={task.completed}
        onChange={handleToggle}
      />
      <span className="task-title">{task.title}</span>
      <button onClick={handleDelete} className="delete-button">
        削除
      </button>

      {/* 一時IDの場合は同期中アイコンを表示 */}
      {task.id.startsWith('temp_') && (
        <span className="sync-indicator"></span>
      )}
    </li>
  );
};

新しいタスクを追加するフォームコンポーネントを実装します。

typescript// components/TaskForm.tsx - タスク追加フォーム

import React, { useState } from 'react';
import { useTaskStore } from '../store/taskStore';

export const TaskForm: React.FC = () => {
  const [title, setTitle] = useState('');
  const addTask = useTaskStore((state) => state.addTask);
  const syncStatus = useTaskStore(
    (state) => state.syncStatus
  );

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!title.trim()) return;

    // 楽観的更新により即座にUIが更新される
    await addTask(title.trim());
    setTitle(''); // フォームをクリア
  };

  return (
    <form onSubmit={handleSubmit} className='task-form'>
      <input
        type='text'
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder='新しいタスクを入力'
        disabled={syncStatus === 'syncing'}
      />
      <button
        type='submit'
        disabled={syncStatus === 'syncing'}
      >
        追加
      </button>
    </form>
  );
};

これらのコンポーネントにより、楽観的 UI が実現されます。ユーザーはボタンをクリックすると即座に UI が更新され、バックグラウンドで同期が行われます。

オフライン検知と同期キュー

オフライン時の操作を保存し、オンライン復帰時に自動的に同期する機能を実装しましょう。

オフライン検知とキュー管理のフローを確認します。

mermaidflowchart TB
  detect["オフライン検知"]
  queue["操作をキューに追加"]
  store["IndexedDB に保存"]
  online["オンライン復帰検知"]
  dequeue["キューから取り出し"]
  sync["順次同期実行"]
  success["成功:キューから削除"]
  retry["失敗:リトライカウント増加"]
  giveup["上限到達:ユーザーに通知"]

  detect --> queue
  queue --> store
  online --> dequeue
  dequeue --> sync
  sync --> success
  sync --> retry
  retry -->|3回未満| dequeue
  retry -->|3回以上| giveup

この図が示すように、オフライン時の操作はキューに保存され、オンライン復帰時に順次実行されます。

同期キューの型定義を行います。

typescript// queue/types.ts - キューの型定義

// キューに保存する操作の型
interface QueuedOperation {
  id: string;
  type: 'create' | 'update' | 'delete';
  payload: any;
  timestamp: number;
  retryCount: number;
  maxRetries: number;
}

// キュー管理用のストア型
interface QueueStore {
  operations: QueuedOperation[];
  isOnline: boolean;
  isProcessing: boolean;

  enqueue: (
    operation: Omit<
      QueuedOperation,
      'id' | 'timestamp' | 'retryCount'
    >
  ) => void;
  dequeue: () => QueuedOperation | undefined;
  processQueue: () => Promise<void>;
  setOnlineStatus: (status: boolean) => void;
}

オフライン検知とキュー処理のストアを実装します。

typescript// queue/queueStore.ts - キュー管理ストア

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { QueueStore, QueuedOperation } from './types';
import { api } from '../api';

export const useQueueStore = create<QueueStore>()(
  persist(
    (set, get) => ({
      operations: [],
      isOnline: navigator.onLine,
      isProcessing: false,

      // キューに操作を追加
      enqueue: (operation) => {
        const queuedOp: QueuedOperation = {
          ...operation,
          id: `queue_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
          timestamp: Date.now(),
          retryCount: 0,
        };

        set((state) => ({
          operations: [...state.operations, queuedOp],
        }));
      },

キューから操作を取り出す処理を実装します。

typescript// queue/queueStore.ts - デキュー処理(続き)

      // キューから操作を取り出し
      dequeue: () => {
        const { operations } = get();
        if (operations.length === 0) return undefined;

        const [first, ...rest] = operations;
        set({ operations: rest });

        return first;
      },

キューの処理ロジックを実装します。リトライ機能も含まれています。

typescript// queue/queueStore.ts - キュー処理ロジック(続き)

      // キュー内の操作を順次処理
      processQueue: async () => {
        const { operations, isOnline, isProcessing } = get();

        // オフラインまたは処理中の場合はスキップ
        if (!isOnline || isProcessing || operations.length === 0) {
          return;
        }

        set({ isProcessing: true });

        while (operations.length > 0) {
          const operation = get().dequeue();
          if (!operation) break;

          try {
            // 操作タイプに応じてAPIを呼び出し
            switch (operation.type) {
              case 'create':
                await api.createTask(operation.payload.title);
                break;
              case 'update':
                await api.updateTask(operation.payload.id, operation.payload.updates);
                break;
              case 'delete':
                await api.deleteTask(operation.payload.id);
                break;
            }

            console.log(`Operation ${operation.id} completed successfully`);

          } catch (error) {
            console.error(`Operation ${operation.id} failed:`, error);

            // リトライ処理
            if (operation.retryCount < operation.maxRetries) {
              set((state) => ({
                operations: [
                  ...state.operations,
                  { ...operation, retryCount: operation.retryCount + 1 },
                ],
              }));
            } else {
              // リトライ上限に達した場合はユーザーに通知
              console.error(`Operation ${operation.id} failed after ${operation.maxRetries} retries`);
              // ここでトースト通知などを表示
            }
          }
        }

        set({ isProcessing: false });
      },

オンライン状態の管理を実装します。

typescript// queue/queueStore.ts - オンライン状態管理(続き)

      // オンライン状態を設定
      setOnlineStatus: (status: boolean) => {
        set({ isOnline: status });

        // オンラインになったらキューを処理
        if (status) {
          get().processQueue();
        }
      },
    }),
    {
      name: 'task-queue', // IndexedDB のキー名
      partialize: (state) => ({ operations: state.operations }), // 永続化する状態を指定
    }
  )
);

オンライン・オフライン検知のフックを実装します。

typescript// hooks/useNetworkStatus.ts - ネットワーク状態監視

import { useEffect } from 'react';
import { useQueueStore } from '../queue/queueStore';

export const useNetworkStatus = () => {
  const setOnlineStatus = useQueueStore(
    (state) => state.setOnlineStatus
  );

  useEffect(() => {
    // 初期状態を設定
    setOnlineStatus(navigator.onLine);

    // オンライン・オフラインイベントを監視
    const handleOnline = () => {
      console.log('Network: Online');
      setOnlineStatus(true);
    };

    const handleOffline = () => {
      console.log('Network: Offline');
      setOnlineStatus(false);
    };

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    // クリーンアップ
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, [setOnlineStatus]);
};

アプリケーション全体でネットワーク状態を監視するように設定します。

typescript// App.tsx - アプリケーションルート

import React from 'react';
import { TaskList } from './components/TaskList';
import { TaskForm } from './components/TaskForm';
import { useNetworkStatus } from './hooks/useNetworkStatus';
import { useQueueStore } from './queue/queueStore';

export const App: React.FC = () => {
  // ネットワーク状態の監視を開始
  useNetworkStatus();

  const isOnline = useQueueStore((state) => state.isOnline);
  const queueLength = useQueueStore(
    (state) => state.operations.length
  );

  return (
    <div className='app'>
      <header>
        <h1>オフラインファースト TODO アプリ</h1>

        {/* ネットワーク状態の表示 */}
        <div
          className={`network-status ${
            isOnline ? 'online' : 'offline'
          }`}
        >
          {isOnline ? '🟢 オンライン' : '🔴 オフライン'}
        </div>

        {/* 未同期の操作数を表示 */}
        {queueLength > 0 && (
          <div className='queue-status'>
            未同期の操作: {queueLength}件
          </div>
        )}
      </header>

      <main>
        <TaskForm />
        <TaskList />
      </main>
    </div>
  );
};

これで、オフライン時でも操作を継続でき、オンライン復帰時に自動的に同期される仕組みが完成しました。

エラーハンドリングとユーザーへの通知

適切なエラーハンドリングとユーザー通知は、オフラインファースト設計の重要な要素です。ユーザーに状況を明確に伝えるトースト通知システムを実装しましょう。

トースト通知の型定義を行います。

typescript// notification/types.ts - 通知の型定義

type NotificationType =
  | 'success'
  | 'error'
  | 'warning'
  | 'info';

interface Notification {
  id: string;
  type: NotificationType;
  message: string;
  duration: number; // ミリ秒
  action?: {
    label: string;
    onClick: () => void;
  };
}

interface NotificationStore {
  notifications: Notification[];
  show: (notification: Omit<Notification, 'id'>) => void;
  hide: (id: string) => void;
}

通知管理のストアを実装します。

typescript// notification/notificationStore.ts - 通知ストア

import { create } from 'zustand';
import { NotificationStore, Notification } from './types';

export const useNotificationStore =
  create<NotificationStore>((set, get) => ({
    notifications: [],

    // 通知を表示
    show: (notification) => {
      const id = `notif_${Date.now()}_${Math.random()
        .toString(36)
        .substr(2, 9)}`;
      const newNotification: Notification = {
        ...notification,
        id,
      };

      set((state) => ({
        notifications: [
          ...state.notifications,
          newNotification,
        ],
      }));

      // 指定時間後に自動的に非表示
      if (notification.duration > 0) {
        setTimeout(() => {
          get().hide(id);
        }, notification.duration);
      }
    },

    // 通知を非表示
    hide: (id) => {
      set((state) => ({
        notifications: state.notifications.filter(
          (n) => n.id !== id
        ),
      }));
    },
  }));

通知コンポーネントを実装します。

typescript// components/NotificationContainer.tsx - 通知コンテナ

import React from 'react';
import { useNotificationStore } from '../notification/notificationStore';

export const NotificationContainer: React.FC = () => {
  const notifications = useNotificationStore(
    (state) => state.notifications
  );
  const hide = useNotificationStore((state) => state.hide);

  return (
    <div className='notification-container'>
      {notifications.map((notification) => (
        <div
          key={notification.id}
          className={`notification notification-${notification.type}`}
        >
          <div className='notification-content'>
            <span className='notification-message'>
              {notification.message}
            </span>

            {notification.action && (
              <button
                className='notification-action'
                onClick={() => {
                  notification.action?.onClick();
                  hide(notification.id);
                }}
              >
                {notification.action.label}
              </button>
            )}
          </div>

          <button
            className='notification-close'
            onClick={() => hide(notification.id)}
          >
            ×
          </button>
        </div>
      ))}
    </div>
  );
};

エラー時に通知を表示するようにストアを更新します。

typescript// store/taskStore.ts - エラー通知の追加

import { useNotificationStore } from '../notification/notificationStore';

// addTask のエラーハンドリング部分を更新
    } catch (error) {
      get().rollback(operationId);

      const errorMessage = error instanceof Error
        ? error.message
        : '保存に失敗しました';

      set({
        syncStatus: 'error',
        error: errorMessage,
      });

      // エラー通知を表示
      useNotificationStore.getState().show({
        type: 'error',
        message: errorMessage,
        duration: 5000,
        action: {
          label: '再試行',
          onClick: () => get().addTask(title),
        },
      });
    }

同様に、他のアクションにも通知を追加します。

typescript// store/taskStore.ts - 成功時の通知

try {
  set({ syncStatus: 'syncing' });
  const savedTask = await api.createTask(title);

  set((state) => ({
    tasks: state.tasks.map((t) =>
      t.id === newTask.id ? savedTask : t
    ),
    syncStatus: 'success',
  }));

  // 成功通知を表示
  useNotificationStore.getState().show({
    type: 'success',
    message: 'タスクを追加しました',
    duration: 3000,
  });

  const { snapshots } = get();
  snapshots.delete(operationId);
  set({ snapshots: cleanupSnapshots(snapshots) });
} catch (error) {
  // エラー処理(前述のコード)
}

オフライン時の操作にも通知を追加します。

typescript// queue/queueStore.ts - キュー追加時の通知

      enqueue: (operation) => {
        const queuedOp: QueuedOperation = {
          ...operation,
          id: `queue_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
          timestamp: Date.now(),
          retryCount: 0,
        };

        set((state) => ({
          operations: [...state.operations, queuedOp],
        }));

        // オフライン時の操作をキューに追加したことを通知
        if (!get().isOnline) {
          useNotificationStore.getState().show({
            type: 'info',
            message: 'オフラインです。オンライン復帰時に同期されます。',
            duration: 4000,
          });
        }
      },

これにより、ユーザーは操作の成功・失敗を明確に把握でき、必要に応じて再試行できます。

パフォーマンス最適化

最後に、パフォーマンス最適化のテクニックを見ていきます。Zustand のセレクター機能を活用した効率的な状態購読です。

typescript// components/TaskList.tsx - 最適化されたセレクター

import { shallow } from 'zustand/shallow';

export const TaskList: React.FC = () => {
  // shallow 比較で不要な再レンダリングを防ぐ
  const { tasks, syncStatus, error } = useTaskStore(
    (state) => ({
      tasks: state.tasks,
      syncStatus: state.syncStatus,
      error: state.error,
    }),
    shallow // シャロー比較を使用
  );

  // 以下、前述のコード
};

個別のタスクアイテムでは、必要な値のみを購読します。

typescript// components/TaskItem.tsx - 個別値の購読

export const TaskItem: React.FC<TaskItemProps> = ({
  task,
}) => {
  // アクションのみを購読(アクションは変更されないため再レンダリングなし)
  const updateTask = useTaskStore(
    (state) => state.updateTask
  );
  const deleteTask = useTaskStore(
    (state) => state.deleteTask
  );

  // task は props で受け取るため、親の tasks が変更された時のみ更新される
};

大量のタスクを扱う場合は、仮想スクロールを検討します。

typescript// components/VirtualTaskList.tsx - 仮想スクロール対応

import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useTaskStore } from '../store/taskStore';
import { TaskItem } from './TaskItem';

export const VirtualTaskList: React.FC = () => {
  const tasks = useTaskStore((state) => state.tasks);
  const parentRef = React.useRef<HTMLDivElement>(null);

  // 仮想スクロールの設定
  const virtualizer = useVirtualizer({
    count: tasks.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50, // 各アイテムの推定高さ(px)
    overscan: 5, // 画面外に事前レンダリングする数
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '400px', overflow: 'auto' }}
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer
          .getVirtualItems()
          .map((virtualItem) => {
            const task = tasks[virtualItem.index];

            return (
              <div
                key={task.id}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  transform: `translateY(${virtualItem.start}px)`,
                }}
              >
                <TaskItem task={task} />
              </div>
            );
          })}
      </div>
    </div>
  );
};

これにより、数千件のタスクでもスムーズにスクロールできます。

まとめ

オフラインファースト設計は、ユーザー体験を大きく向上させる重要なアーキテクチャです。Zustand を使えば、楽観的 UI とロールバック機能を少ないコードで実装できます。

本記事で紹介した内容をまとめると、以下のようになります。

#要素実装内容メリット
1楽観的 UI即座に UI 更新体感速度の向上
2スナップショット操作前の状態保存確実なロールバック
3同期キューオフライン操作の保存データロスの防止
4リトライ機構失敗時の自動再試行信頼性の向上
5通知システム状態のフィードバックユーザー体験の向上

Zustand の軽量さとシンプルさは、オフラインファースト設計と相性が良く、複雑な状態管理をわかりやすく実装できます。スナップショット機能により、どんな操作でも安全にロールバックでき、ユーザーは安心して操作を続けられるのです。

オフライン対応は、モバイルアプリだけでなく、Web アプリケーションでも重要性が増しています。接続が不安定な環境でも快適に利用できるアプリは、ユーザーの満足度を大きく向上させます。

ぜひ、本記事で紹介した手法を活用して、より良いユーザー体験を提供するアプリケーションを開発してみてください。

関連リンク