フォームの送信ボタンを押してから、サーバーの応答が返ってくるまでの待ち時間は、ほんの一瞬でもユーザーには「固まった」ように感じられます。この待ち時間の体感をなくすための手法が「楽観的更新(optimistic update)」です。React 19 で追加された useOptimistic フックを使うと、非同期処理が完了する前に UI を「成功したはず」という前提で先に更新し、処理が終わったら本物の状態に置き換える、という実装が手軽に書けます。この記事では、useOptimistic の基本的なシグネチャから、メッセージ送信フォームを例にした Server Action との組み合わせ、そしてつまずきやすいポイントまでを順を追って解説します。
目次
楽観的更新とは何か、なぜ必要か
チャットアプリでメッセージを送る場面を思い浮かべてください。素直に実装すると、送信ボタンを押す → サーバーに送信する → サーバーから「保存できた」という応答が返る → その応答を受けてメッセージ一覧に追加する、という流れになります。この場合、ネットワークの往復が終わるまで画面には何も変化が起きません。回線が遅ければ、ユーザーは自分の操作が受け付けられたのか分からず不安になります。
楽観的更新は、この流れを逆転させます。サーバーの応答を待たず、送信ボタンを押した瞬間に「たぶん成功するだろう」という前提でメッセージ一覧に先回りで表示してしまうのです。実際の保存はバックグラウンドで進み、完了したら仮の表示を本物のデータに差し替えます。もし失敗したら仮の表示を取り消して元に戻します。ユーザーから見れば操作が即座に反映されるので、体感速度が大きく向上します。useOptimistic は、この「仮の状態を一時的に見せて、あとで本物に置き換える」という仕組みを React の作法に沿って実装するためのフックです。
useOptimistic の基本的な書き方
useOptimistic は次のシグネチャで呼び出します。第1引数に「本物の状態」を、第2引数に「楽観的な値を受け取って新しい状態を作る更新関数」を渡します。
const [optimisticState, addOptimistic] = useOptimistic( state, // 現在の本物の state updateFn, // (currentState, optimisticValue) => newState );
戻り値と引数の意味を整理すると、次のようになります。
| 名前 | 種類 | 説明 |
|---|---|---|
state | 引数 | 非同期処理がないときに表示する、本物の状態の初期値。 |
updateFn | 引数 | (currentState, optimisticValue) => newState の形をした純粋関数。現在の状態と楽観的な値を受け取り、マージした新しい状態を返す。 |
optimisticState | 戻り値 | 実際に画面へ表示する状態。処理中は楽観的な値が反映され、処理完了で本物の state に戻る。 |
addOptimistic | 戻り値 | 楽観的な更新をトリガーする関数。addOptimistic(optimisticValue) のように呼ぶ。 |
ポイントは、optimisticState が「処理中は仮の値、処理が終われば本物の値」というふうに自動で切り替わることです。開発者は本物の state を管理し、楽観的に見せたいときだけ addOptimistic を呼べばよく、仮の値を本物に戻す後始末は React が引き受けてくれます。
addOptimistic を呼ぶと何が起きるか
addOptimistic(value) を呼び出すと、その瞬間に updateFn(現在の状態, value) が実行され、返ってきた結果が optimisticState として即座に画面へ反映されます。ここまではサーバーの応答を一切待ちません。
そのあとバックグラウンドで進んでいた非同期処理(トランジション)が完了し、本物の state が更新されると、optimisticState は自動的にその本物の値へ置き換わります。ここで重要なのは、処理が完了した時点で楽観的な値は破棄され、optimisticState は本物の state をそのまま映すようになる、という挙動です。もし処理が失敗して state が更新されなかった場合も、トランジションが終われば楽観的な値は捨てられ、表示は元の state に戻ります。つまり、仮表示のロールバックは明示的に書かなくても自動で行われます。
メッセージ送信フォームで実践する
ここからは具体例として、メッセージを送信するフォームを作ります。送信ボタンを押した瞬間に「送信中…」という印を付けたメッセージを一覧へ楽観的に追加し、サーバー処理が終わったら確定させる、という動きです。Server Action を form の action に渡す構成で見ていきます。
まず、フォームを表示するコンポーネントです。useOptimistic で楽観的なメッセージ一覧を作り、フォームの action に渡した関数の中で addOptimistic を呼び出します。
"use client";
import { useOptimistic, useRef } from "react";
import { sendMessage } from "./actions";
type Message = {
text: string;
sending?: boolean; // 送信中かどうかの目印
};
export function Thread({ messages }: { messages: Message[] }) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticMessages, addOptimistic] = useOptimistic<
Message[],
string
>(messages, (currentMessages, newText) => [
...currentMessages,
{ text: newText, sending: true }, // 仮のメッセージを末尾に追加
]);
async function formAction(formData: FormData) {
const text = formData.get("message") as string;
addOptimistic(text); // 送信した瞬間に楽観的に追加
formRef.current?.reset();
await sendMessage(text); // Server Action でサーバーへ送信
}
return (
<div>
{optimisticMessages.map((message, index) => (
<div key={index}>
{message.text}
{message.sending && <small>(送信中…)</small>}
</div>
))}
<form action={formAction} ref={formRef}>
<input type="text" name="message" placeholder="メッセージを入力" />
<button type="submit">送信</button>
</form>
</div>
);
}
ここで updateFn は「現在のメッセージ配列に、sending: true を付けた仮のメッセージを1件足す」という処理をしています。formAction の中で addOptimistic(text) を呼んだ瞬間、この仮メッセージが一覧に現れ、「(送信中…)」という表示が付きます。await sendMessage(text) が完了すると、サーバー側の本物のメッセージ一覧が反映され、仮メッセージは自動的に本物へ置き換わります。
フォームの action に渡した関数は React によってトランジションの中で実行されるため、その内側で呼ぶ addOptimistic も正しくトランジションに紐づきます。次に、サーバー側の sendMessage を用意します。
"use server";
export async function sendMessage(text: string) {
// 実際にはデータベースへ保存するなどの非同期処理を行う
await saveToDatabase(text);
// 一覧を再取得したいときは revalidatePath などを呼ぶ
}
このように、UI 側は「押した瞬間に仮表示」、サーバー側は「落ち着いて保存」という役割分担になります。ユーザーには送信が即座に反映されたように見え、実際の保存はその裏で進みます。
トランジションの外で呼ぶと反映されない
useOptimistic を使い始めて最初に戸惑いやすいのが、addOptimistic を呼ぶ場所です。addOptimistic は、必ずトランジションの内側で呼ぶ必要があります。具体的には、form の action に渡した関数の中や、startTransition でラップした処理の中です。上の例では form action={formAction} の formAction がトランジションとして扱われるため、その中の addOptimistic は問題なく機能します。
逆に、ボタンの onClick の中で普通に addOptimistic を呼ぶだけだと、楽観的な状態がすぐに元へ戻ってしまい、意図した仮表示が持続しません。form を使わずに手動で発火させたい場合は、次のように startTransition の中に非同期処理ごと入れます。
import { startTransition } from "react";
function handleClick(text: string) {
startTransition(async () => {
addOptimistic(text); // トランジションの中で呼ぶ
await sendMessage(text);
});
}
楽観的な表示は、そのトランジションが続いている間だけ保持され、完了すると本物の状態に戻ります。だからこそ、addOptimistic は非同期処理と同じトランジションの中で呼ぶ必要がある、と覚えておくとよいでしょう。
失敗したときのエラー表示は別途用意する
もう一つ意識しておきたいのが、失敗時の扱いです。前述のとおり、非同期処理が失敗して本物の state が更新されなければ、optimisticState は自動的に元の状態へ戻ります。つまり、追加したはずの仮メッセージは静かに消えます。これはロールバックとしては便利ですが、ユーザーには「送ったはずのメッセージが消えた」だけに見え、なぜ失敗したのかが伝わりません。
useOptimistic が面倒を見てくれるのは、あくまで「仮表示を元に戻す」ところまでです。失敗をユーザーに知らせるエラーメッセージの表示や、入力内容の復元といった処理は、自分で書く必要があります。Server Action の呼び出しを try/catch で囲んでエラー状態を別の state に保持し、それを画面に出す、といった実装を組み合わせてください。楽観的更新は「成功する前提で先に見せる」仕組みなので、失敗したときのフォローは開発者側の責任になる、という点を押さえておきましょう。
まとめ
useOptimistic は、非同期処理の完了を待たずに UI を「成功したはず」の状態で先に更新する、楽観的更新のための React 19 のフックです。const [optimisticState, addOptimistic] = useOptimistic(state, updateFn) という形で、本物の状態と更新関数を渡して使います。addOptimistic を呼べば即座に仮の状態が画面へ反映され、非同期処理が完了すると本物の状態へ自動的に置き換わり、失敗時も自動でロールバックされます。使ううえでの勘所は2つで、addOptimistic は必ず form の action や startTransition といったトランジションの中で呼ぶこと、そして失敗時のエラー表示は自動では出ないので別途自分で用意することです。この2点を押さえれば、ネットワークの待ち時間を感じさせない、即応的な UI を無理なく実装できます。