JOURNAL

React 19のActions完全攻略!useActionStateとuseOptimisticで脱ボイラープレート

全国のWebコーダー・フロントエンドエンジニアのみなさま、こんにちは!赤猫座の道具店の店主(黒猫&白猫のしもべ)です🐱

モダンなWebフロントエンド開発において、世界中で最も愛用されているUIライブラリ「React」。2024年末に待望のメジャーバージョン「React 19」が正式リリースされ、すでに多くのプロジェクトで導入が進んでいます。

React 19といえばReact Compiler(コンパイラによるメモ化自動化)やServer Components(RSC)が大きな注目を集めがちですが、日々のアプリケーション開発で「もっとも開発者体験(DX)とコードの美しさを劇的に変えた機能」を挙げるなら、間違いなく「Actions(アクション)」関連の新機能群です!✨

これまでReactでフォーム送信やデータ更新を行う際、私たちは何十行もの「定型コード(ボイラープレート)」を書き殴ってきました。

「const [isSubmitting, setIsSubmitting] = useState(false) を毎回書くの飽きた……」
「try { ... } catch (err) { setError(err) } finally { setIsSubmitting(false) } の手動リセットでバグった……!」
「サーバーの返答を待たずにUIを即時更新したい(楽観的UI)けど、ロールバック処理が複雑すぎて諦めた……」

そんな「フォームと非同期状態管理の苦痛」を一撃で過去のものにするのが、React 19の新フック useActionState と useOptimistic、そして useFormStatus です。🐾

今回は、React 19のActionsパラダイムの基礎から実践まで、従来のReact 18コードとの「圧倒的なビフォー・アフター」を交えて徹底的に解説します!

🐱 この記事でわかること(要点まとめ)
  • Actionsの真価:非同期関数の実行状態(pending・戻り値・エラー)をReactが自動管理。手動のローディングフラグやtry-catch地獄が消滅!
  • useActionState:フォーム送信の戻り値・状態・実行中フラグ(isPending)をたった1行で宣言的にバインド。
  • useOptimistic:サーバーレスポンスを待たずに0ミリ秒でUIを先行反映&エラー発生時は自動で安全にロールバック。
  • useFormStatus:PropsのバケツリレーやContextを作ることなく、親フォームの送信状態を子コンポーネントから直接取得。
  • 完全脱ボイラープレート:従来のコードと比較してコード行数を半減させ、堅牢で快適なUIを実現する実践パターン。

1. なぜReactのフォーム・非同期状態管理は辛かったのか? 〜従来の3大苦痛〜

React 19のActionsのありがたみを実感するために、まずは私たちがReact 18以前で書いてきた「おなじみの苦行」を振り返ってみましょう。

苦痛①:useState の乱立(ローディング・エラー・データの3段構え)

たった1つのフォーム送信を作るだけでも、以下のように複数のステートを個別に定義する必要がありました。

  • isSubmitting / isLoading(送信中フラグ)
  • error / errorMessage(エラーメッセージ)
  • data / result(送信後の結果データ)
  • 各入力欄のバインド用ステート(制御コンポーネントの場合)

これらを手動で同期させようとすると、送信開始時に setError(null); setIsSubmitting(true);、成功時に setData(res); setIsSubmitting(false);、失敗時に setError(err); setIsSubmitting(false); といったステート更新があちこちに散らばり、状態の不整合(ローディングが戻らない等)の温床になっていました。

苦痛②:e.preventDefault() と非同期例外処理の定型文

HTMLネイティブの <form> は送信時に画面全体をリロードするため、Reactでは必ず onSubmit={(e) => e.preventDefault()} を書き、JavaScriptの非同期関数を呼び出していました。ネイティブのフォーム機構と完全に断絶した独自の実装パターンを強いられていたのです。

苦痛③:楽観的UI更新(Optimistic UI)の破綻リスク

SNSの「いいね」ボタンやチャットのメッセージ送信のように、「サーバーからの返信を待たずに画面を先に更新する」UIを自前で実装しようとすると、以下の難題に直面します。

  • 先行してローカルステートを書き換える
  • API通信がエラーになった場合に、直前の正しい状態へと確実に巻き戻す(ロールバック)
  • 通信中にユーザーがさらに別の操作を行った場合の競合(レースコンディション)の調停

これを素のReactで安全に作るのは極めて難しく、多くの現場で「ローディングスピナーをぐるぐる回して待たせる」妥協が行われていました。

2. React 19のパラダイムシフト:「Actions(アクション)」とは?

React 19では、「非同期処理を伴うトランジション(Transitions)」を第一級市民として扱い、それを「Actions」と定義しました。

Actionsの基本的な思想は非常にシンプルです。

💡 Actionsの定義と特徴
  • 非同期関数をそのまま渡せる:<form action={asyncFunction}> や useActionState に async 関数を渡すだけで、Reactが非同期トランジションとして認識。
  • ライフサイクルの自動追跡:非同期関数が実行中の間、Reactが自動的に isPending フラグを true にし、完了すると自動で false に戻す。
  • ネイティブ FormData の活用:フォーム内の各入力値が FormData インスタンスとしてAction関数に直接渡されるため、各入力値の useState が不要に。
  • フォームの自動リセット:Actionが正常完了すると、非制御フォームの入力欄が自動的に初期状態へクリアされる。

これにより、Next.jsなどのフルスタックフレームワーク(Server Actions)はもちろん、純粋なクライアントサイドのSPA(Vite + React等)でも、全く同じ直感的な作法でフォームと非同期処理を記述できるようになりました。

3. 【新フック①】useActionState による劇的な脱ボイラープレート

React 19で最も頻繁に使うことになるフックが useActionState です。(※開発途中のCanary版では useFormState と呼ばれていたものが正式版でリネームされ、シグネチャが改善されました)

useActionState の基本構文

useActionState のシグネチャ
const [state, formAction, isPending] = useActionState(actionFunction, initialState, permalink?);
  • 第1引数(actionFunction):(previousState, formData) => Promise<newState> というシグネチャの非同期(または同期)関数。
  • 第2引数(initialState):初期状態の値(エラー初期値や初期データ)。
  • 返り値 state:Action関数の最新の戻り値(エラーメッセージや登録結果オブジェクトなど)。
  • 返り値 formAction:<form action={formAction}> や <button formAction={formAction}> にそのまま渡す関数。
  • 返り値 isPending:Action実行中(Promiseが解決するまで)自動的に true になる真偽値。

【コード比較】ユーザー名変更フォーム(Before vs After)

それでは、実際のコードで比較してみましょう!

❌ 従来の書き方(React 18以前:約40行のボイラープレート)

React 18:useState + onSubmit + try/catch
import { useState } from 'react';

export function UpdateNameForm() {
  const [name, setName] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);
    setSuccess(false);

    try {
      const res = await updateNameApi(name);
      if (res.error) {
        setError(res.error);
      } else {
        setSuccess(true);
        setName(''); // 手動で入力値をクリア
      }
    } catch (err) {
      setError('通信エラーが発生しました');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        disabled={isSubmitting}
        placeholder="新しいお名前"
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '保存中...' : '更新'}
      </button>
      {error && <p className="error">{error}</p>}
      {success && <p className="success">更新しました!</p>}
    </form>
  );
}

✨ React 19の書き方(useActionState:たったの15行!)

React 19:useActionState + <form action>
import { useActionState } from 'react';

// Action関数:(直前のstate, 送信されたFormData) を受け取る
async function updateNameAction(prevState: any, formData: FormData) {
  const newName = formData.get('name') as string;
  const res = await updateNameApi(newName);
  return res; // { error?: string, success?: boolean }
}

export function UpdateNameForm() {
  const [state, formAction, isPending] = useActionState(updateNameAction, null);

  return (
    <form action={formAction}>
      <input
        name="name"
        type="text"
        disabled={isPending}
        placeholder="新しいお名前"
      />
      <button type="submit" disabled={isPending}>
        {isPending ? '保存中...' : '更新'}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p className="success">更新しました!</p>}
    </form>
  );
}

いかがでしょうか?以下の変化が一目瞭然です。

  • useState による isSubmitting、error、success、name の4つの状態管理が完全に消滅しました。
  • e.preventDefault() や try-catch-finally の手動リセットが一切不要になりました。
  • 入力欄は name="name" を指定するだけの非制御コンポーネントとなり、キー入力ごとの無駄な再レンダリングも防げます。

4. 【新フック②】useOptimistic による超高速な楽観的UI更新

続いて紹介するのが、Webアプリの体感速度をネイティブアプリ並みに引き上げる useOptimistic です。

「楽観的UI更新(Optimistic Updates)」とは、サーバーへのAPIリクエストを投げると同時に、「リクエストは成功するはずだ」と楽観視して、画面の表示を0ミリ秒で即座に切り替える手法です。

useOptimistic の仕組みとシグネチャ

useOptimistic のシグネチャ
const [optimisticState, setOptimistic] = useOptimistic(passthroughState, updateFn);
  • 第1引数(passthroughState):サーバー等から受け取った「実際の確定データ(PropsやState)」。
  • 第2引数(updateFn):(currentState, optimisticValue) => newOptimisticState という純粋関数。
  • 返り値 optimisticState:非同期処理中は先行反映されたデータ、非同期処理が完了すると確定データへと自動同期された値。
  • 返り値 setOptimistic:Actionの実行直前に先行反映用の値を渡す関数。

【実践例】SNSの「いいね!」ボタンを即時反映する

ボタンを押した瞬間にハートが赤くなりカウントが増加。もしネットワークエラーが発生したら自動で元の状態へロールバックされる完璧な実装が、これだけで書けます。

useOptimistic を使った「いいね!」コンポーネント
import { useOptimistic, useTransition } from 'react';

type LikeProps = {
  likesCount: number;
  isLiked: boolean;
  onLikeApi: () => Promise<{ likesCount: number; isLiked: boolean }>;
};

export function LikeButton({ likesCount, isLiked, onLikeApi }: LikeProps) {
  // 楽観的ステートの定義
  const [optimisticLike, setOptimisticLike] = useOptimistic(
    { likesCount, isLiked, sending: false },
    (state, update: boolean) => ({
      likesCount: update ? state.likesCount + 1 : state.likesCount - 1,
      isLiked: update,
      sending: true, // 送信中フラグ
    })
  );

  const [isPending, startTransition] = useTransition();

  const handleToggleLike = () => {
    const nextLiked = !optimisticLike.isLiked;

    startTransition(async () => {
      // 1. サーバーを待たずにUIを即時更新!
      setOptimisticLike(nextLiked);

      // 2. 実際のサーバー通信を実行
      await onLikeApi();
      // ※ 通信が完了すると自動的にPropsの確定値に同期され、
      //    例外発生時は自動的にロールバックされます!
    });
  };

  return (
    <button
      onClick={handleToggleLike}
      className={`like-btn ${optimisticLike.isLiked ? 'is-active' : ''}`}
      aria-label="いいね"
    >
      <span className="heart">{optimisticLike.isLiked ? '❤️' : '🤍'}</span>
      <span className="count">{optimisticLike.likesCount}</span>
      {optimisticLike.sending && <span className="sync-badge">同期中...</span>}
    </button>
  );
}

このフックの魔法のような利点は、「ロールバック処理を1行も書く必要がない」という点です。Action内のPromiseが解決または拒否(エラー)されると、Reactが自動的に passthroughState の実データに戻して再描画してくれます。

5. 【新フック③】useFormStatus でボタンやインジケータを分離する

フォームが大きくなってくると、「送信ボタン」や「プログレスバー」を別ファイルにコンポーネント分割したくなりますよね。

従来のReactでは、親フォームから disabled={isSubmitting} をPropsで子コンポーネントへ渡すか、Form用のカスタムContextを作成する必要がありました。しかしReact 19の useFormStatus を使えば、子コンポーネントが最も近い親の <form> の状態を直接読み取ることができます!

再利用可能な汎用 SubmitButton コンポーネント
import { useFormStatus } from 'react-dom';

export function SubmitButton({ children }: { children: React.ReactNode }) {
  // 親の <form> の状態を自動取得
  const { pending, data, method, action } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className={`btn-submit ${pending ? 'is-loading' : ''}`}
    >
      {pending ? (
        <span className="spinner">処理中...🐾</span>
      ) : (
        children
      )}
    </button>
  );
}
⚠️ useFormStatus 使用時の超重要ルール

useFormStatus は、「<form> タグの内部にネストされた子コンポーネントの中」で呼び出す必要があります。同じコンポーネント内に <form> と useFormStatus を同居させて呼んでも、親フォームが存在しないと判定されて pending: false のままになるので注意してください。

6. 【実践統合サンプル】3大フックで作る「リアルタイム・コメント投稿UI」

それでは、ここまでに学んだ useActionState + useOptimistic + useFormStatus のすべてを融合させた、実践的な「コメント投稿機能」の完全版コードを見てみましょう!

「送信ボタンを押した瞬間にコメント一覧に自分の投稿が即時追加され、裏でAPI通信が行われ、完了すると正式データに置き換わる」という極上のUXを構築します。

CommentSection.tsx(Actionsフル活用パターン)
import { useActionState, useOptimistic, useRef } from 'react';
import { useFormStatus } from 'react-dom';

type Comment = {
  id: string;
  text: string;
  author: string;
  isSending?: boolean;
};

// 共通送信ボタンコンポーネント
function SubmitBtn() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} className="btn-primary">
      {pending ? '送信中...' : 'コメントを投稿する'}
    </button>
  );
}

// 親コンポーネント
export function CommentSection({ initialComments }: { initialComments: Comment[] }) {
  const formRef = useRef<HTMLFormElement>(null);

  // 1. useActionState でコメント一覧の更新ロジックを定義
  const [comments, formAction] = useActionState(
    async (prevComments: Comment[], formData: FormData) => {
      const text = formData.get('comment') as string;
      const author = '赤猫座の常連さん';

      // 2. 楽観的に先行反映
      setOptimisticComments({
        id: crypto.randomUUID(),
        text,
        author,
        isSending: true,
      });

      // フォームの入力欄をクリア
      formRef.current?.reset();

      // 3. 実際のAPI通信
      const savedComment = await apiPostComment({ text, author });
      return [...prevComments, savedComment];
    },
    initialComments
  );

  // 4. useOptimistic で先行表示用リストを作成
  const [optimisticComments, setOptimisticComments] = useOptimistic(
    comments,
    (state, newComment: Comment) => [...state, newComment]
  );

  return (
    <div class="comment-widget">
      <h3>コメント一覧({optimisticComments.length}件)</h3>

      <ul className="comment-list">
        {optimisticComments.map((c) => (
          <li key={c.id} className={c.isSending ? 'comment--pending' : ''}>
            <strong>{c.author}</strong>
            <p>{c.text}</p>
            {c.isSending && <small className="badge">送信中...</small>}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={formAction} className="comment-form">
        <textarea
          name="comment"
          required
          placeholder="あたたかい感想をお待ちしています🐾"
        />
        <SubmitBtn />
      </form>
    </div>
  );
}

このわずか数十行のコードの中に、以下の高度なUI機能がすべて内包されています。

  • ボタンを押した瞬間の0秒先行描画(useOptimistic)
  • 非同期通信と結果配列の自動状態更新(useActionState)
  • 送信中のボタン二重押し防止とローディング表示(useFormStatus)
  • 成功時のフォーム自動リセットと、失敗時の安全な自動巻き戻し

7. React 18からの移行ポイントと注意点

最後に、既存プロジェクトをReact 19へ移行する際の注意点を整理しておきます。

① useFormState から useActionState への名称変更と引数順序

Next.js 14などの canary 版で先行導入されていた useFormState(react-dom からインポート)は非推奨(Deprecated)となり、React 19では useActionState(react コアパッケージからインポート)へ統一されました。

また、引数の順序も変更されています。

  • 旧 useFormState: useFormState(fn, initialState, permalink)(react-dom)
  • 新 useActionState: useActionState(fn, initialState, permalink)(react)※返り値の3番目に isPending が追加

② クライアントActionとServer Actions(Next.js等)の区別

React 19の「Actions」という言葉は、必ずしも「サーバーで実行される処理(Server Actions)」だけを指すわけではありません。

  • Client Actions:ブラウザ上で動く純粋な非同期関数(fetch() を叩く関数など)。ViteやSPAでもフル活用可能。
  • Server Actions:ファイルの先頭に 'use server' を書き、Node.js等のサーバー環境で実行される関数。

どちらの関数であっても、useActionState や <form action> にそのまま渡せる設計になっています。

8. まとめ & 店主のつぶやき:ボイラープレートを脱ぎ捨てて本質へ

React 19のActionsは、単なる「便利なフックの追加」にとどまらず、「Reactにおける非同期通信と状態管理の標準化」を達成した歴史的アップデートです。

これまではライブラリ(TanStack Query / SWR / Formik / React Hook Formなど)を入れないと快適に書けなかったフォームや非同期の状態管理が、React標準のフックだけでこれほど美しく、宣言的に書けるようになりました。

余分なボイラープレートコードを書く時間が減れば、そのぶん「UIの心地よいアニメーション」や「ユーザーにとって本当に使いやすい機能の追求」に情熱を注ぐことができますよね!🐾

「React 19の新しいコードを書いていたら、CSSの単位やベジェ曲線の調整でこだわりたくなってきた……!」というときは、赤猫座の道具店に並ぶ「イージング生成機(BEZIER LAB)」や「CSS Clamp Scope」などのWeb道具たちもぜひ活用してみてくださいね。

🐱 本日のまとめ
  • Actions:非同期関数を渡すだけで、pending状態やライフサイクルをReactが自動管理する新標準。
  • useActionState:フォーム送信の戻り値・エラー・ローディングを1行で宣言的にバインド。
  • useOptimistic:0ミリ秒でUIを即座に先行反映し、失敗時は安全に自動ロールバック。
  • useFormStatus:Props不要で親フォームの送信状態を取得し、ボタン部品をスマートに共通化。
  • 脱ボイラープレート:手動の useState 乱立や try-catch-finally 地獄とサヨナラしよう!