第130章:練習:useOptimistic でTODOリストに即時反映させる
useOptimistic で TODO リストに「即時反映」させよう ✅
この章のゴールはただひとつ 💪
フォームで TODO を送信した瞬間に、サーバーの完了を待たずにリストに表示される UI を作ること!
いわゆる 「楽観的更新(optimistic update)」 を、useOptimistic と フォーム Action で実装してみます。
React 19 では useOptimistic が公式のフックとして追加されていて、
「非同期処理が終わるまでのあいだ、一時的な“見かけの state” を表示する」ために使えます。(React)
1️⃣ 全体の流れイメージ(図でざっくり)
useOptimistic を使った TODO 追加の流れを、ざっくり図解してみます ✨
ポイントはこの2つだけ ✍️
- 画面に出すのは
optimisticTodos(楽観的な配列) - 本物のデータは
todos(普通の state) として別で持っておく
useOptimistic(state, updateFn) は、
「本物の state を渡すと、そのコピーを“楽観的バージョン”として返してくれる」フックです。(React)
2️⃣ 今日作るコンポーネントのイメージ 🧩
- 画面中央にシンプルな TODO リスト 📝
- 上にテキストボックス+「追加」ボタン
- 送信した瞬間にリストに追加される
- 裏では
setTimeoutを使って「サーバーに保存中」を再現(ダミーの非同期処理)
ファイル構成イメージ(Vite + React-TS プロジェクト):
src/App.tsxsrc/OptimisticTodoList.tsx← 今日作る!src/index.css← ちょっとだけ見た目を整える
3️⃣ OptimisticTodoList.tsx の骨組みを作る 🦴
まずは 型と普通の state だけ用意して、骨だけ作ります。
src/OptimisticTodoList.tsx を作って、こんな感じで書きます 👇
import { useState, useOptimistic } from "react";
type Todo = {
id: number;
title: string;
};
// サーバーに送っている“つもり”のダミー関数
async function fakePostTodo(todo: Todo): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
console.log("サーバーに保存完了(という設定)", todo);
resolve();
}, 1000);
});
}
function OptimisticTodoList() {
// 本物の state(サーバーと同期させたいデータ)
const [todos, setTodos] = useState<Todo[]>([]);
// ここに useOptimistic をあとで追加するよ!✨
return (
<div className="todo-container">
<h2>useOptimistic TODO 📝</h2>
<form className="todo-form">
<input
name="title"
placeholder="やることを入力してね"
className="todo-input"
/>
<button type="submit" className="todo-button">
追加
</button>
</form>
<ul className="todo-list">
{/* TODO: ここに TODO を並べて表示する */}
</ul>
</div>
);
}
export default OptimisticTodoList;
まだ useOptimistic は空っぽですが、まずは雰囲気だけ作りました 🎀
4️⃣ useOptimistic を追加しよう ✨
次に、いよいよ useOptimistic を使います。
公式の型はこんな感じ(ざっくり)です:(React)
const [optimisticState, addOptimistic] = useOptimistic<State, Update>(state, updateFn);
State→ 楽観的に扱いたい state の型(ここではTodo[])Update→ 「こういう変更を加えたいよ」という入力の型(ここではTodo)
さっきのコンポーネントに、useOptimistic をこう追加します 👇
function OptimisticTodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
// useOptimistic を使って「楽観的な TODO リスト」を用意する
const [optimisticTodos, addOptimisticTodo] = useOptimistic<Todo[], Todo>(
todos,
(currentTodos, newTodo) => [...currentTodos, newTodo]
);
// ...
}
ここでやっていること 💡
-
todos- 本物の配列(サーバーと同期させたい真の state)
-
optimisticTodos- 画面に表示するための「一時的に盛られた version」
- フォーム送信後すぐに、ここに新しい TODO を差し込む
-
addOptimisticTodo(newTodo)updateFnを使って、optimisticTodosを更新してくれる関数
5️⃣ <form action={...}> の Action 関数を書く 🧾
React 19 では、<form> の action 属性に 非同期関数 をそのまま渡せます。
フォームが送信されると、その関数が呼ばれて FormData が渡ってきます。(React)
ここではその Action 関数の中で ①
addOptimisticTodoを呼んで画面を即更新 ② ダミーのサーバー保存(fakePostTodo) ③ 成功したら本物のtodosにも反映 という流れにします 🌈
OptimisticTodoList の中身を、ここまでを全部つなげて完成形にします 👇
import { useState, useOptimistic } from "react";
type Todo = {
id: number;
title: string;
};
async function fakePostTodo(todo: Todo): Promise<void> {
return new Promise((resolve) => {
setTimeout(() => {
console.log("サーバーに保存完了(という設定)", todo);
resolve();
}, 1000);
});
}
function OptimisticTodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [optimisticTodos, addOptimisticTodo] = useOptimistic<Todo[], Todo>(
todos,
(currentTodos, newTodo) => [...currentTodos, newTodo]
);
// フォームが送信されたときに呼ばれる Action 関数
async function addTodoAction(formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string") {
return;
}
const trimmed = title.trim();
if (!trimmed) {
return;
}
const newTodo: Todo = {
id: Date.now(), // 簡単なID。実務ではもっとちゃんとしたIDを使うことが多いよ
title: trimmed,
};
// ① 楽観的に画面だけ先に更新する ✨
addOptimisticTodo(newTodo);
try {
// ② サーバーに送信している“つもり”の非同期処理
await fakePostTodo(newTodo);
// ③ 本物の state にも反映
setTodos((prev) => [...prev, newTodo]);
} catch (error) {
console.error(error);
alert("保存に失敗しました…😢");
// エラー時は「本物の state」をそのまま再セットして
// useOptimistic に「やっぱり元の状態で!」と伝えるイメージ
setTodos((prev) => [...prev]);
}
}
return (
<div className="todo-container">
<h2>useOptimistic TODO 📝</h2>
{/* React 19 の Form Actions */}
<form action={addTodoAction} className="todo-form">
<input
name="title"
placeholder="やることを入力してね"
className="todo-input"
/>
<button type="submit" className="todo-button">
追加
</button>
</form>
<ul className="todo-list">
{optimisticTodos.map((todo) => (
<li key={todo.id} className="todo-item">
{todo.title}
</li>
))}
</ul>
</div>
);
}
export default OptimisticTodoList;
👀 ここがポイント!
-
画面に表示しているのは
todosではなくoptimisticTodos -
addTodoActionの中で- 先に
addOptimisticTodo(newTodo)→ すぐ表示 - あとで
setTodos→ 本物の state もあとから追いつく
- 先に
-
todosが変化すると、useOptimisticはそれをベースにして 「楽観的状態」をリセット・再計算してくれます。(azukiazusa.dev)
6️⃣ App.tsx から呼び出す 🏠
src/App.tsx でこのコンポーネントを表示してあげます。
import OptimisticTodoList from "./OptimisticTodoList";
function App() {
return (
<div>
<h1 style={{ textAlign: "center" }}>React 19 Form Actions × useOptimistic ✨</h1>
<OptimisticTodoList />
</div>
);
}
export default App;
7️⃣ ちょこっと CSS でかわいくする 🎀
src/index.css に、こんなスタイルを足してみてください(自由にアレンジしてOK!)
.todo-container {
max-width: 480px;
margin: 2rem auto;
padding: 1.5rem;
border-radius: 16px;
background-color: #f5f5ff;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.06);
}
.todo-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.todo-input {
flex: 1;
padding: 0.6rem 0.9rem;
border-radius: 999px;
border: 1px solid #d4d4ff;
font-size: 0.95rem;
}
.todo-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.todo-button {
padding: 0.6rem 1rem;
border-radius: 999px;
border: none;
background-color: #6366f1;
color: white;
font-weight: 600;
cursor: pointer;
}
.todo-button:hover {
filter: brightness(1.05);
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-item {
padding: 0.5rem 0.75rem;
margin-bottom: 0.4rem;
border-radius: 8px;
background-color: white;
}
8️⃣ 仕上げ:自分の手で改造してみようチャレンジ 💪🔥
ここからは「自分で考えて手を動かす」タイムです ⏱
お題①:ローディングっぽさを出してみる
-
TODO を追加したあと 1秒間、画面のどこかに 「サーバー保存中…⏳」 みたいな表示を出してみる
-
ヒント:
-
useState<boolean>でisSavingを追加してみる -
addTodoActionの中でsetIsSaving(true);→await fakePostTodo→setIsSaving(false);
-
お題②:エラーのときに「元に戻る」UI にしてみる
-
fakePostTodoの中で、たまにthrow new Error("エラー")してみて、 エラーが出たときに「楽観的 TODO を消す」挙動を試してみる -
ヒント:
- エラー時に、
setTodosで「エラー前の配列」を入れてあげる - そのタイミングで
useOptimisticが ベースの state を元に戻すイメージ
- エラー時に、
お題③:useFormStatus と組み合わせてみる(やれたら上級者✨)
-
以前の章で登場した
useFormStatusを使って、 送信ボタンのラベルを- 通常: 「追加」
- 送信中: 「送信中...🚀」 に切り替えてみる
まとめ ✨
useOptimisticは 「非同期処理のあいだだけ、UI を“いい感じの見かけ”にしておく」ためのフック でした。(React)- React 19 の
<form action={async (formData) => { ... }}>と組み合わせると、 フォーム送信 → 楽観的更新 → サーバー完了 の流れがとてもシンプルに書けます。(React) - この章の TODO リストは、実務で使うもっと大きなフォームの ミニ版サンプル だと思ってください 🌱
ここまで作れたら、 「サーバー待ちで固まらない、気持ちのいいフォーム UI」の第一歩クリアです 🎉👏