はじめに

Reactで楽観的更新を入れると、操作直後にUIが反応するため体験はよく見える。
たとえば「いいね」「削除」「並び替え」「カート追加」などでは、サーバー応答を待たずに画面を更新したくなる。

ただし、レビューではここで立ち止まりたい。

楽観的更新の危険は、単に「失敗したら戻せばよい」という話ではない。
実際には、戻す前に別の操作が重なる、サーバー側の結果が想定と違う、UIだけ成功したように見えるといった状態不整合が起きる。

この記事では、Reactの楽観的更新を全面否定するのではなく、レビューアーがどこを確認し、どこから差し戻すべきかを整理する。

まず止めたい実装

次の実装は、ボタンを押した瞬間にローカル状態を更新し、その後でAPIを呼んでいる。

楽観的更新の失敗処理がない例
type Todo = {
  id: string;
  title: string;
  done: boolean;
};

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useState(initialTodos);

  async function toggleTodo(id: string) {
    setTodos(current =>
      current.map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );

    await fetch(`/api/todos/${id}/toggle`, { method: "POST" });
@Reviewer
UIを先に更新していますが、API失敗時のrollbackや再同期がありません。画面上は成功したように見えたまま、サーバー状態とずれる可能性があります。
} return ( <ul> {todos.map(todo => ( <li key={todo.id}> <label> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} /> {todo.title} </label> </li> ))} </ul> ); }

このコードは見た目だけなら自然だ。
しかしレビューでは、成功時ではなく失敗時と競合時を読む必要がある。

なぜ危険か

楽観的更新では、UI状態とサーバー状態の時系列が一時的に分かれる。

1. ユーザーがチェックする
2. React state は done=true になる
3. APIにPOSTする
4. 通信失敗、認可エラー、競合更新のどれかが起きる
5. 画面だけ done=true のまま残る

このとき、ユーザーから見ると操作は成功したように見える。
しかし実際には保存されていないため、再読み込みや別画面遷移で状態が戻る。

レビューで問題にするべきなのは、楽観的更新そのものではなく、失敗時にどの状態へ戻すのかが設計されていないことである。

レビュー観点1:rollbackできる元状態を保持しているか

楽観的更新を許容するなら、少なくとも更新前の状態を復元できる必要がある。

rollbackの起点がある例
async function toggleTodo(id: string) {
  const previousTodos = todos;

  setTodos(current =>
    current.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    )
  );

  try {
    const response = await fetch(`/api/todos/${id}/toggle`, { method: "POST" });
    if (!response.ok) {
      throw new Error("failed to toggle todo");
    }
  } catch {
    setTodos(previousTodos);
    setErrorMessage("更新に失敗しました。もう一度お試しください。");
  }
}

ただし、この形でも完全ではない。
previousTodos は関数実行時点の配列なので、API待ちの間に別の更新が入ると、巻き戻しで別操作まで消す可能性がある。

Comment
@Reviewer: rollback用の状態はありますが、待機中に別操作が入った場合に配列全体を戻すと他の更新まで巻き戻ります。対象ID単位で戻すか、サーバー状態を再取得する方針を明示してください。

レビュー観点2:連打と並行更新を止めているか

楽観的更新で見落としやすいのが連打である。

連打で反転が重なる例
async function toggleTodo(id: string) {
  setTodos(current =>
    current.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    )
  );

  await fetch(`/api/todos/${id}/toggle`, { method: "POST" });
}

toggle APIは特に危ない。
同じリクエストを2回送ると、最終状態が元に戻ることがあるため、UIの反転回数とサーバーの反映回数がずれやすい。

レビューでは次を確認したい。

  • 更新中の項目だけ操作不能にしているか
  • toggle ではなく done=true のように目標状態を送っているか
  • 多重送信時の最後の操作をどう扱うか決まっているか
  • サーバー応答で最終状態を上書きしているか
目標状態を送る例
async function setTodoDone(id: string, nextDone: boolean) {
  setPendingIds(current => new Set(current).add(id));
  setTodos(current =>
    current.map(todo =>
      todo.id === id ? { ...todo, done: nextDone } : todo
    )
  );

  try {
    const response = await fetch(`/api/todos/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ done: nextDone }),
    });

    const savedTodo = await response.json();
    setTodos(current =>
      current.map(todo => (todo.id === id ? savedTodo : todo))
    );
  } finally {
    setPendingIds(current => {
      const next = new Set(current);
      next.delete(id);
      return next;
    });
  }
}

レビュー観点3:サーバー応答を正として扱っているか

楽観的更新では、クライアント側が「こうなるはず」と仮定してUIを先に変える。
しかし最終的な正はサーバー応答である。

たとえばサーバー側で次の処理が入ることがある。

  • 権限により更新が拒否される
  • バリデーションにより値が丸められる
  • 更新日時や集計値が再計算される
  • 他ユーザーの更新と競合する

そのため、成功時にもサーバー応答で該当項目を置き換える設計がレビューしやすい。

Comment
@Reviewer: 成功時にクライアント側の推定状態をそのまま残しています。サーバー側で正規化や競合解決が入る可能性があるため、レスポンスの保存済み状態で再同期してください。

レビュー観点4:エラー表示の責務が曖昧でないか

楽観的更新の失敗は、ユーザーに見える必要がある。
ただし、各ボタンの中で alert() を呼んだり、一覧全体の上に雑なエラーを出したりすると、責務が散る。

レビューでは、エラーが次のどれなのかを分けて読む。

エラー種別 UIでの扱い
一時的な通信失敗 元に戻して再試行導線を出す
認可エラー 操作不可状態へ戻し、権限表示を更新する
競合更新 サーバー状態を再取得して差分を見せる
入力エラー 対象フィールドや対象行に紐づけて表示する

エラー表示は単なる文言ではなく、状態復元の一部である。
ここが曖昧な実装は、レビューで差し戻してよい。

改善の方向性

楽観的更新を安全に寄せるなら、少なくとも次の構造がほしい。

レビューしやすい責務分離の例
async function updateTodoDone(id: string, nextDone: boolean) {
  markTodoPending(id);
  applyOptimisticTodo(id, { done: nextDone });

  try {
    const savedTodo = await saveTodoDone(id, nextDone);
    replaceTodo(savedTodo);
  } catch (error) {
    await reloadTodo(id);
    showTodoError(id, "更新に失敗しました");
  } finally {
    unmarkTodoPending(id);
  }
}

この例で重要なのは、関数を細かくしたことではない。
楽観的反映、保存、再同期、エラー表示、pending解除の責務が読めることだ。

レビューコメント例

Comment
@Reviewer: 楽観的更新自体は問題ありませんが、失敗時の復元方針が見えません。対象項目だけを戻すのか、一覧を再取得するのかを明示し、API失敗時にサーバー状態とUI状態がずれたまま残らない構造にしてください。
Comment
@Reviewer: `toggle` を多重送信すると最終状態が読みづらくなります。APIには反転操作ではなく目標状態を送り、保存後はサーバー応答で該当項目を置き換える形に寄せたいです。

まとめ

Reactの楽観的更新は、体験をよくするための有効な手段である。
しかしレビューでは、成功時の軽快さではなく、失敗時と並行操作時の整合性を読む必要がある。

確認したい線は明確だ。

  • 失敗時に戻せるか
  • 連打や並行更新で壊れないか
  • サーバー応答を正として再同期しているか
  • エラー表示が状態復元とつながっているか

この4点が見えない楽観的更新は、見た目が自然でも差し戻す価値がある。