C
Next.js/レンダリング/Lesson 08

Optimistic UI — useActionState + useFormStatus + useOptimistic

35分·theory
このチャプター
5/5
TypeScript

Optimistic UI — useActionState + useFormStatus + useOptimistic

💡 なぜ学ぶべきか? — 「クリック後のわずかな待ち」がなくなる

🎯 いいねボタンを押してからサーバーの応答(200〜500ms)を待ってUIが変わると、ユーザーは「ちゃんと押せたのかな?」と不安になります。
💼 Optimistic UI:クリック直後にUIを「成功したかのように」表示し、サーバーの応答が返ってきたら実際の結果を反映。失敗した場合は自動ロールバック。
React 19の`useOptimistic`フックによってこのパターンが標準化されました — TwitterやInstagramのいいねのような即時反応UXを実現します。
🔗 `useActionState`(旧useFormState)+ `useFormStatus` + `useOptimistic` の3つのフックが合わさることで、Server Actionのフォー厶UXが完成します。
📈 ユーザー体験として:クリック直後にレスポンス → 本当に速いアプリという印象。
🏢 실무에서는
いいね・ブックマーク・フォロー・カート追加 — ユーザーが毎日何十回もクリックするアクションです。毎回0.3秒の待ちが積み重なると「遅いサイト」という印象につながります。`useOptimistic`を使えばクリック直後にカウント+1を表示し、サーバーから実際の結果が返ってきたら照合。失敗時は-1を自動ロールバックしてエラーメッセージを表示します。

3つの hook — 役割分担

1. 各 hook の役割

Hook位置役割
useActionStateフォーム内Server Action の戻り値を state として受け取る。pending を追跡
useFormStatusフォームの子要素最も近い親 form の pending 状態を読み取る
useOptimisticフォームの兄弟・子要素サーバーレスポンス前に UI を即座に更新、失敗時に自動ロールバック

2. useActionState(React 19 — 旧 useFormState の代替)

tsx
import { useActionState } from 'react';

const [state, formAction, isPending] = useActionState(
  serverAction,           // 実行する Server Action
  initialState,           // state の初期値
);

<form action={formAction}>...</form>

Action のシグネチャが (prevState, formData) => newState に変わります。

3. useFormStatus — 子要素が親 form の状態を読み取る

tsx
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '保存中...' : '保存'}</button>;
}

親の <form> 内のどこにでも配置すれば、自動的にそのフォームの状態を取得できます。props のバケツリレー不要。

4. useOptimistic — 即座の UI 更新 + 自動ロールバック

tsx
import { useOptimistic } from 'react';

function LikeButton({ likes }: { likes: number }) {
  // optimistic state — 実際の状態とは別に「予想値」を保持
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (current, increment: number) => current + increment,  // reducer
  );

  async function handleClick() {
    addOptimisticLike(1);  // UI を即座に +1
    await likeAction();    // サーバー呼び出し
    // 完了後、React が自動的に実際の likes 値に置き換える
    // 失敗した場合は optimistic 値を破棄し、元の値を復元
  }

  return <button onClick={handleClick}>❤️ {optimisticLikes}</button>;
}

5. 3つを組み合わせる — 完成したフォーム

tsx
'use client';
import { useActionState, useOptimistic } from 'react';
import { useFormStatus } from 'react-dom';
import { addCommentAction } from './actions';

export function CommentForm({ comments }: { comments: Comment[] }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newText: string) => [...state, { id: Date.now(), text: newText, pending: true }],
  );

  const [state, formAction] = useActionState(
    async (prev: { error: string | null }, formData: FormData) => {
      const text = formData.get('text') as string;
      addOptimisticComment(text);                  // UI に即座に追加
      return await addCommentAction(prev, formData); // サーバー処理
    },
    { error: null },
  );

  return (
    <div>
      <ul>
        {optimisticComments.map(c => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text} {c.pending && '(送信中...)'}
          </li>
        ))}
      </ul>
      <form action={formAction}>
        <input name="text" required />
        <SubmitButton />
        {state.error && <p>{state.error}</p>}
      </form>
    </div>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '...' : '保存'}</button>;
}
💻 🅰️ 従来の方式 — クリック後にレスポンスを待つ、「押せたかな?」という不安
// ❌ Optimisticなし — サーバー応答を待ってからUI変更
'use client';
import { useState, useTransition } from 'react';
import { likeAction } from './actions';

export function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();

  async function handleClick() {
    startTransition(async () => {
      // ユーザー: クリック → 200~500ms停止 → カウント変更
      const result = await likeAction();
      setLikes(result.likes);
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      ❤️ {likes} {isPending && '(処理中...)'}
    </button>
  );
}

// 欠点:
// - クリック → 0.3秒停止 → カウント変更 → ユーザー「押されたかな?」
// - isPending表示を追加しても不自然さは残る
💻 🅱️ useOptimistic — クリック即 +1、サーバーはバックグラウンドで処理
// ✅ useOptimistic — 即時反応 + 自動同期
'use client';
import { useOptimistic, useTransition } from 'react';
import { likeAction } from './actions';

export function LikeButton({ initialLikes, postId }: { initialLikes: number; postId: number }) {
  // optimistic state定義 — 実際のlikesとは別
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current, increment: number) => current + increment,
  );
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      addOptimisticLike(1);              // UI即時+1
      try {
        await likeAction(postId);
        // 成功: Reactが自動的に実際の値に置き換え
      } catch (e) {
        // 失敗: Reactが自動的にoptimistic値を破棄、元の値に復元
        console.error('いいね失敗:', e);
      }
    });
  }

  return (
    <button onClick={handleClick}>
      ❤️ {optimisticLikes}
    </button>
  );
}

// 📁 app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function likeAction(postId: number) {
  await db.like.create({ data: { postId } });
  revalidatePath(`/posts/${postId}`);
  // revalidate後、サーバーが新しいlikes値を返し → optimistic値と同期
}

// ユーザー体感:
// クリック → 即時 ❤️ 543 → 545 (0ms)
// サーバー応答: 545確定 (バックグラウンド、ユーザーは知らない)
// もし失敗: ❤️ 543に自動復元 + エラートースト

// コメントリストも同じパターン — 即時新しいコメントを追加、「送信中」表示
export function CommentList({ initial }: { initial: Comment[] }) {
  const [optimistic, addOptimisticComment] = useOptimistic(
    initial,
    (state, newText: string) => [
      ...state,
      { id: Date.now(), text: newText, pending: true },
    ],
  );

  // ... formAction内でaddOptimisticComment(formData.get('text'))を呼び出し
  return (
    <ul>
      {optimistic.map(c => (
        <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
          {c.text} {c.pending && '(送信中...)'}
        </li>
      ))}
    </ul>
  );
}

💡 💡 Optimistic UI 実践 5 つのポイント

1. useOptimistic は startTransition 内で呼び出す

tsx
startTransition(async () => {
  addOptimisticLike(1);
  await action();
});

Transition なしで呼び出すと同期更新になり、効果が薄くなります。

2. optimistic 項目に pending: true などのマーカーを付ける
「まだサーバーで確認されていない」という視覚的シグナルをユーザーに提供(opacity 0.5、「送信中」ラベルなど)。

3. 失敗時の追加コード不要 — React が自動ロールバック
Server Action が throw した場合、optimistic state は破棄され元の値が復元されます。catch でエラートーストを追加するだけで十分。

4. revalidatePath 後に実際の値が届いたら自動同期
Server Action の末尾で revalidatePath を呼び出す → ページデータを再取得 → useOptimistic の initial 値が更新 → optimistic が自動破棄。

5. useActionState と組み合わせる — フォーム全体の状態管理

tsx
const [state, formAction] = useActionState(action, initial);
const [optimistic, addOpt] = useOptimistic(state.items, reducer);
// フォームエラー → state、即時 UI → optimistic

⚡ 実際に試してみる — Optimistic UI シナリオ

成功・失敗のケースで optimistic state がどのように動作するかをシミュレーションします。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

理解度チェック

Server Action を呼び出した後にサーバーのレスポンスが失敗した場合、useOptimistic の値はどうなりますか?
💡 `useOptimistic`はTransition内で使用され、actionがthrowまたはrejectされるとReactが自動的にoptimistic stateを破棄して元の値に復元します。つまりユーザーは一瞬+1を見てから自動的に元の値に戻るのを確認します。catchでエラートーストを表示するだけで十分 — 手動ロールバックのコードは不要です。
Optimistic UI — useActionState + useOptimistic - Next.js