はじめに
CloudWatchのエラーログを確認していたら、見覚えのない400エラーがあって画面上は正常に動作しているのに、なぜかAPIが400を返しているという問題がありました。
調べてみると、原因は楽観的更新で使っていた一時IDでした!!
本記事では、この問題の原因と解決策を共有します。
この記事で解決する問題
楽観的更新では、サーバーからIDが返ってくる前にUIを更新するために、クライアント側で一時的なID(例: temp-xxx)を生成することがあります。
この一時IDが親子関係を持つデータ構造で伝播すると、子データの取得時に存在しないIDへAPIリクエストが飛んでしまいます。
楽観的更新とは
楽観的更新は、APIリクエストの完了を待たずに、UIを先に更新する手法のことです。
通常の更新:
- ユーザー操作 → APIリクエスト → レスポンス待ち → UI更新
楽観的更新:
- ユーザー操作 → UI即時更新 → APIリクエスト(バックグラウンド)
最大のメリットはユーザーが操作の結果を即座に確認できて、アプリケーションが高速に感じられることです。
SWRやTanStack Queryなどのデータフェッチングライブラリでは、この楽観的更新を簡単に実装できる仕組みが提供されています。
問題が起きた状況
データ構造:プロジェクト → タスク → サブタスク(親子関係)
- 新しいタスクを作成する
- 楽観的更新により、サーバー応答前に仮のID(temp-xxx) でUIに追加される
- 直後に、そのタスクのサブタスク一覧を取得しようとする
- /api/tasks/temp-xxx/subtasks というリクエストが飛ぶ
- サーバーには temp-xxx というIDは存在しない → 400 Bad Request
図解で解説
解決方法
Before(問題のあるコード)
export function useSubtasksSWR(taskId: string) {
return useSWR(
['subtasks', taskId],
() => subtasksApi.list(taskId)
);
}
export function useProjectSWR(projectId: string) {
return useSWR(
['project', projectId],
() => projectsApi.get(projectId)
);
}
export function useTasksMutation() {
const createTask = async (projectId: string, taskData: TaskInput) => {
const tempId = `temp-${Date.now()}`;
// 楽観的にキャッシュを更新
mutate(
['project', projectId],
(project) => ({
...project,
taskIds: [...project.taskIds, tempId],
}),
{ revalidate: false }
);
const newTask = await tasksApi.create(projectId, taskData);
// 本物のIDで置き換え
mutate(['project', projectId]);
return newTask;
};
return { createTask };
}
function SubtaskList({ taskId }: { taskId: string }) {
// taskId が "temp-xxx" の場合、存在しないIDへリクエストが飛ぶ
const { data: subtasks } = useSubtasksSWR(taskId);
return <ul>{subtasks?.map(s => <li key={s.id}>{s.title}</li>)}</ul>;
}
function TaskList({ projectId }: { projectId: string }) {
const { data: project } = useProjectSWR(projectId);
const { createTask } = useTasksMutation();
const handleAddTask = () => {
createTask(projectId, { title: '新しいタスク' });
};
if (!project) return null;
return (
<div>
<button onClick={handleAddTask}>タスク追加</button>
{project.taskIds.map(taskId => (
<div key={taskId}>
<TaskItem taskId={taskId} />
<SubtaskList taskId={taskId} /> {/* temp-xxx でAPIエラー */}
</div>
))}
</div>
);
}
After(修正後のコード)
function TaskList({ projectId }: { projectId: string }) {
const { data: project } = useProjectSWR(projectId);
const { createTask } = useTasksMutation();
const handleAddTask = () => {
createTask(projectId, { title: '新しいタスク' });
};
if (!project) return null;
// 一時IDを除外
const confirmedTaskIds = project.taskIds.filter(
(id) => !id.startsWith('temp-')
);
return (
<div>
<button onClick={handleAddTask}>タスク追加</button>
{confirmedTaskIds.map(taskId => (
<div key={taskId}>
<TaskItem taskId={taskId} />
<SubtaskList taskId={taskId} /> {/* 確定したIDのみ */}
</div>
))}
</div>
);
}
ポイント
- 一時ID(
temp-)はUIの即時反映用 - APIリクエストを発行する箇所では、一時IDを除外する
- フィルタリングは「APIを呼ぶ直前」で行う
まとめ
問題
楽観的更新で使用する一時ID(temp-xxx)が親子関係を持つデータで子要素に伝播し、存在しないIDへのAPIリクエストが発生して400エラーになる。画面上は正常に見えるため気づきにくい。
発生パターン
- 新規タスク作成時に一時ID(temp-xxx)で楽観的更新
- 直後にそのタスクの子データ(サブタスク)取得が走る
- /api/tasks/temp-xxx/subtasks へリクエスト → 400エラー
解決策
APIリクエストを発行する箇所で一時IDを除外する
const confirmedTaskIds = project.taskIds.filter(
(id) => !id.startsWith('temp-')
);
ポイント:
- 一時IDはUI即時反映用として残す
- APIを呼ぶ直前でフィルタリングする
