Zustandでの非同期処理とfetch連携パターン(パターン 2: 楽観的更新(Optimistic Updates))

Zustand を使った非同期処理パターンの中でも、ユーザー体験を大幅に向上させる「楽観的更新(Optimistic Updates)」は特に重要なテクニックです。この記事では、Zustand を使った楽観的更新の実装方法とそのメリットについて詳しく解説します。
パターン 2: 楽観的更新(Optimistic Updates)
ユーザー体験を向上させるための効果的な手法として、「楽観的更新」があります。この手法では、サーバーからの応答を待たずに、操作が成功すると「楽観的に」想定して UI を即座に更新します。
ユースケース: Todo リストの管理
Todo 項目の完了状態を切り替えるユースケースで、楽観的更新を実装してみましょう:
typescript// src/stores/todoStore.ts
import { create } from 'zustand';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoStore {
todos: Todo[];
isLoading: boolean;
error: string | null;
// アクション
fetchTodos: () => Promise<void>;
toggleTodo: (id: string) => Promise<void>;
}
export const useTodoStore = create<TodoStore>(
(set, get) => ({
todos: [],
isLoading: false,
error: null,
fetchTodos: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/todos');
if (!response.ok)
throw new Error('Failed to fetch todos');
const todos = await response.json();
set({ todos, isLoading: false });
} catch (error) {
set({
error:
error instanceof Error
? error.message
: '未知のエラー',
isLoading: false,
});
}
},
toggleTodo: async (id) => {
// 現在のtodosを取得
const currentTodos = get().todos;
const todoToUpdate = currentTodos.find(
(todo) => todo.id === id
);
if (!todoToUpdate) return;
// 更新前の状態をバックアップ(ロールバック用)
const previousTodos = [...currentTodos];
// 楽観的に即時更新(UIにすぐに反映)
set({
todos: currentTodos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
),
});
try {
// サーバーへ更新を送信
const response = await fetch(
`/api/todos/${id}/toggle`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
completed: !todoToUpdate.completed,
}),
}
);
if (!response.ok)
throw new Error('Failed to update todo');
// サーバーからのレスポンスでステートを更新することもできる
// この例では楽観的更新で十分なので省略
} catch (error) {
// エラーが発生した場合、前の状態に戻す(ロールバック)
set({
todos: previousTodos,
error:
'更新に失敗しました。もう一度お試しください。',
});
}
},
})
);
コンポーネントの実装
このストアを使用した Todo リストコンポーネントの例:
tsx// src/components/TodoList.tsx
import React, { useEffect } from 'react';
import { useTodoStore } from '../stores/todoStore';
export const TodoList: React.FC = () => {
const {
todos,
isLoading,
error,
fetchTodos,
toggleTodo,
} = useTodoStore();
useEffect(() => {
fetchTodos();
}, []);
if (isLoading && todos.length === 0) {
return <div>読み込み中...</div>;
}
if (error) {
return <div className='error-message'>{error}</div>;
}
return (
<div className='todo-list'>
{error && <div className='error-banner'>{error}</div>}
<ul>
{todos.map((todo) => (
<li
key={todo.id}
className={todo.completed ? 'completed' : ''}
onClick={() => toggleTodo(todo.id)}
>
<span className='checkbox'>
{todo.completed ? '✓' : '□'}
</span>
<span className='todo-text'>{todo.text}</span>
</li>
))}
</ul>
</div>
);
};
楽観的更新のメカニズム
楽観的更新パターンの実装では、以下の手順が重要です:
-
現在の状態をバックアップ: 更新前の状態をコピーして保存します。これにより、エラー発生時にロールバックできます。
-
UI の即時更新: サーバーからのレスポンスを待たずに、予想される結果に基づいて UI を更新します。
-
サーバー処理: バックグラウンドでサーバーに更新リクエストを送信します。
-
エラー処理: サーバー処理が失敗した場合、バックアップした状態に戻し(ロールバック)、エラーメッセージを表示します。
-
サーバーレスポンスの反映(オプション): サーバーからの応答データに基づいて状態を微調整することもできます。
応用例:複数アイテムの楽観的更新
複数の項目を一度に更新する場合の楽観的更新パターン:
typescript// 複数Todoの一括完了/未完了切り替え
const toggleAllTodos = async (completed: boolean) => {
const currentTodos = get().todos;
// 状態のバックアップ
const previousTodos = [...currentTodos];
// 楽観的に全てのTodoを更新
set({
todos: currentTodos.map((todo) => ({
...todo,
completed,
})),
});
try {
// サーバーに一括更新リクエスト
const response = await fetch('/api/todos/toggle-all', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed }),
});
if (!response.ok)
throw new Error('Failed to update todos');
// 成功時の処理(必要に応じて)
} catch (error) {
// エラー時にロールバック
set({
todos: previousTodos,
error: '一括更新に失敗しました',
});
}
};
トランザクション管理との併用
複数の楽観的更新が同時に発生する可能性がある場合、トランザクション ID を使って競合を防ぐことができます:
typescript// トランザクションを使った楽観的更新
const updateWithTransaction = async (id, updates) => {
// トランザクションIDの生成
const transactionId = Date.now().toString();
// 現在の状態を保存
const previousState = { ...get() };
// 楽観的に更新して、トランザクションIDを記録
set((state) => ({
...state,
items: state.items.map((item) =>
item.id === id ? { ...item, ...updates } : item
),
pendingTransactions: [
...state.pendingTransactions,
transactionId,
],
}));
try {
// サーバーに更新を送信
const response = await fetch(`/api/items/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Transaction-ID': transactionId,
},
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error('Update failed');
// 成功したトランザクションを削除
set((state) => ({
...state,
pendingTransactions: state.pendingTransactions.filter(
(id) => id !== transactionId
),
}));
} catch (error) {
// エラー時に特定のトランザクションのみをロールバック
// (複雑なケースでは部分的なロールバックロジックが必要)
set({
...previousState,
error: '更新に失敗しました',
});
}
};
楽観的更新の利点と注意点
利点
-
応答性の向上: ユーザーは操作の結果をすぐに確認でき、アプリケーションが高速に感じられます。
-
ネットワーク遅延の隠蔽: 遅いネットワーク接続でも良好なユーザー体験を提供できます。
-
オフライン対応の基盤: オフラインでの操作をキューに入れる仕組みの基礎となります。
注意点
-
データの整合性: サーバーでの処理が失敗した場合、UI とサーバーの状態が一時的に不整合となります。
-
複雑な更新処理: 相互依存関係のある更新や、順序に依存する更新では注意が必要です。
-
エラー処理の重要性: エラー時のロールバック処理が適切に実装されていないと、データの不整合が永続化する可能性があります。
-
ユーザーへのフィードバック: エラー発生時には明確なフィードバックを提供し、必要に応じて再試行の選択肢を示すべきです。
まとめ
楽観的更新パターンは、Zustand の柔軟性を活かした非同期処理の実装方法として、特にユーザー体験の向上に大きく貢献します。このパターンの実装の鍵は以下の点です:
- 現在の状態のバックアップ: エラー時のロールバック用に状態を保存する
- 即時 UI 更新: サーバーの応答を待たずに UI を更新する
- バックグラウンド処理: サーバーへの実際の更新リクエストを送信
- エラーハンドリング: 失敗時には状態を元に戻し、適切なフィードバックを提供
楽観的更新は、特にユーザーインタラクションが多いアプリケーション(TODO リスト、メモアプリ、チャット、ソーシャルメディアなど)で威力を発揮します。ただし、複雑なデータ関係を持つアプリケーションでは、エラー時の状態ロールバックについて慎重に設計する必要があります。
Zustand のシンプルな状態管理モデルは、このようなパターンを実装する際の複雑さを最小限に抑えるのに役立ちます。
関連リンク
- Zustand 公式ドキュメント
- React Query - Optimistic Updates - React Query での楽観的更新
- MDN - Web Storage API - オフラインデータ保存
- オフラインファースト設計 - PWA とオフライン対応
- UI 設計パターン - ユーザー体験と応答時間の関係