1. ホーム
  2. React

【React】useOptimistic の使い方|楽観的更新でUIを即座に反映する

Share

フォームの送信ボタンを押してから、サーバーの応答が返ってくるまでの待ち時間は、ほんの一瞬でもユーザーには「固まった」ように感じられます。この待ち時間の体感をなくすための手法が「楽観的更新(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 を formaction に渡す構成で見ていきます。

まず、フォームを表示するコンポーネントです。useOptimistic で楽観的なメッセージ一覧を作り、フォームの action に渡した関数の中で addOptimistic を呼び出します。

app/thread.tsx
"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 を用意します。

app/actions.ts
"use server";

export async function sendMessage(text: string) {
  // 実際にはデータベースへ保存するなどの非同期処理を行う
  await saveToDatabase(text);
  // 一覧を再取得したいときは revalidatePath などを呼ぶ
}

このように、UI 側は「押した瞬間に仮表示」、サーバー側は「落ち着いて保存」という役割分担になります。ユーザーには送信が即座に反映されたように見え、実際の保存はその裏で進みます。

トランジションの外で呼ぶと反映されない

useOptimistic を使い始めて最初に戸惑いやすいのが、addOptimistic を呼ぶ場所です。addOptimistic は、必ずトランジションの内側で呼ぶ必要があります。具体的には、formaction に渡した関数の中や、startTransition でラップした処理の中です。上の例では form action={formAction}formAction がトランジションとして扱われるため、その中の addOptimistic は問題なく機能します。

逆に、ボタンの onClick の中で普通に addOptimistic を呼ぶだけだと、楽観的な状態がすぐに元へ戻ってしまい、意図した仮表示が持続しません。form を使わずに手動で発火させたい場合は、次のように startTransition の中に非同期処理ごと入れます。

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 は必ず formactionstartTransition といったトランジションの中で呼ぶこと、そして失敗時のエラー表示は自動では出ないので別途自分で用意することです。この2点を押さえれば、ネットワークの待ち時間を感じさせない、即応的な UI を無理なく実装できます。

参考ページ