はじめに
対象者
楽観的な更新について知りたい人
useOptimisticの使い方を知りたい人
前提条件
一覧取得および新規追加用のAPIが用意されていること
基本的なNext.jsの使い方がわかる
環境
Next.js ver 15, App Router, API Route, Prisma, Tailwindcss
当記事ではPrismaとAPI Routeを使ってAPIを構築しています。 使えればなんでもOKです
記事の目的
公式ドキュメントを読むだけではなく、実際に使って検証するため
解説しないこと
Next.jsの詳細な使い方
サーバーアクション
詳細なAPIの作成方法
流れ
一般的なFormを作成した後、useOptimisticを使用して楽観的なUIの更新ができるように変更していきます。
😀今回はAPI Routeを使用しています。サーバーコンポーネントから直接DB操作やサーバーアクションを使わない理由は、サーバーでの処理を別技術に書き換えるといったことをする時に捨てにくくなるからです。また実務では現状APIは別のフレームワークや言語で切り離されていることが多いため、API Routeを使っておいた方が実務に生きるかなと思います。 サーバーアクションやサーバーコンポーネントからの直接的なDB操作はNext.jsでフルスタックに作れる便利な機能ですが、上記の理由でAPI Routeを使います。
楽観的(optimistic)な更新とは?
楽観的な更新とは、「データ処理が完了する前に、UIを更新する」ことです。
通常のTodoを1件追加する場合の流れ
データを更新するリクエストを投げる → 結果が返る → 成功なら再度一覧データをリクエスト → UIを更新
楽観的なTodoを1件追加する場合の流れ
UIを更新 → データを更新するリクエストを投げる → 結果が返る → 成功なら再度一覧データをリクエスト → UIを更新
これによりユーザの変更がいち早くUIに反映することが可能になります。また、loading画面を挟む必要がなくなるのもメリットです。
SNSのいいね機能はボタンを押した瞬間に反映されますが、楽観的にUIを先に更新しています。
API
APIは以下2つを用意してください。
方法はなんでもOKですが、サンプルとしてPrismaで作成したコードの一部を置いておきます
※ 最小限に簡略化してますので、調整をお願いします。
- todo一覧
- todo新規追加
Todoの型
type Todo = {
id: number; // autoIncrementでもuuidでもなんでもOK
text: string;
}
GET /todos 一覧API
// 返却値
{
todos: Todo[]
}
POST /todos 新規作成API
// リクエスト時のBODY内容
{
title: "text"
}
src/app/api/todos/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// 一覧
export async function GET() {
try {
const todos = await prisma.todo.findMany();
return NextResponse.json({ todos });
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "todoの取得に失敗しました" },
{ status: 500 }
);
}
}
// 新規作成
export async function POST(req: NextRequest) {
// 楽観的な更新を確認しやすいように3秒間遅延させます
await new Promise((resolve) => setTimeout(resolve, 3000));
try {
const { title } = await req.json();
const todo = await prisma.todo.create({
data: {
title,
},
});
return NextResponse.json({ message: "success", todo });
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "todoの作成に失敗しました" },
{ status: 500 }
);
}
}
一般的な更新方法
src/app/todos/page.tsx
import React from "react";
import TodoApp from "./TodoApp";
async function Page() {
// 初回はサーバーコンポーネントでデータを取得します
const res = await fetch('http://localhost:3000/api/todos')
const data = await res.json()
return <TodoApp initialTodos={data.todos} />
}
export default Page;
src/app/todos/component/TodoApp.tsx
"use client";
import { Todo } from "@prisma/client";
import React, { useState } from "react";
type Props = {
initialTodos: Todo[]
}
function TodoApp({ initialTodos }: Props) {
const [text, setText] = useState("");
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const fetchTodos = async () => {
const res = await fetch("/api/todos");
return await res.json()
};
const handleSubmit = async () => {
try {
const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ title: text }),
});
if (res.ok) {
const data = await fetchTodos();
setTodos(data.todos);
setText("");
}
} catch (err) {
console.error(err);
}
};
return (
<>
{/* Form部分 */}
<input
type="text"
className="border"
onChange={(e) => setText(e.target.value)}
value={text}
/>
<button onClick={handleSubmit}>送信</button>
{/* 一覧UI */}
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
);
}
export default TodoApp;
現在の状態は以下のようになります。
送信ボタンを押した後、3秒後にデータの追加が反映されます。
3秒間フリーズしたように見えます。
楽観的な更新
楽観的な更新を行い、先にUIの更新を行います。
"use client";
import { Todo } from "@prisma/client";
import React, { useState, useOptimistic } from "react";
type Props = {
initialTodos: Todo[]
}
// 楽観的な更新時にsendingプロパティを追加するので、型を拡張しておく
type OptimisticTodos = Todo & {
sending?: boolean
}
function TodoApp({ initialTodos }: Props) {
const [text, setText] = useState("");
const [todos, setTodos] = useState<Todo[]>(initialTodos);
// 楽観的な更新をするためのhooks
const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (state: OptimisticTodos[], newTodo: string) => {
const lastTodo = state[state.length - 1]
const id = lastTodo.id + 1;
return [...state, {
id: id,
title: newTodo,
sending: true
}]
})
const fetchTodos = async () => {
const res = await fetch("/api/todos");
return await res.json()
};
// 楽観的な更新用の処理を追加
const handleSubmit = async () => {
// トランジション開始
startTransition(async () => {
// 楽観的な更新をトリガーする
addOptimisticTodo(text)
try {
const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ title: text }),
});
if (res.ok) {
const data = await fetchTodos();
// 成功時は再fetchしてtodosを更新する
setTodos(data.todos)
setText("");
}
} catch (err) {
console.error(err);
}
})
};
return (
<>
<input
type="text"
className="border"
onChange={(e) => setText(e.target.value)}
value={text}
/>
<button onClick={handleSubmit}>送信</button>
{/* todos → optimisticTodos */}
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
);
}
export default TodoApp;
変更点は以下
- 楽観的な更新時にsendingプロパティを追加するための型を定義
- 楽観的な更新のためのhooks useOptimisticを使用
- 一覧表示でtodosではなく、optimisticTodosを回して表示
- handleSubmitで楽観的更新用の処理を追加
更新の流れ
1 送信ボタンを押す
2 addOptimisticTodoで楽観的更新をトリガーする
3 楽観的にUIが更新される
4 todo追加リクエストを送る
5 成功すれば再度todo一覧をfetchして上書きする
楽観的な更新はstartTransitionの中で行う必要があります。
UIの更新後にtodoの新規作成に失敗した場合、startTransition内の処理が終了する → 元の状態変数(todos)が変更されていない → 楽観的なUIの更新をrollbackという流れで戻ります。
違いは登録処理中は...送信中が表示されることです。
リクエストの結果を待たずにUIを更新することができています。
参考

