useActionStateとは、フォーム送信などの「アクション」の結果(成功メッセージやエラー)と、処理中かどうかを1つのフックで管理できるReact 19のフックです。useFormStatusは、親のformが送信中かどうかを、formの内側に置いた部品から読み取るためのフックです。 どちらも form の action 属性に関数を渡す書き方と組み合わせて使います。Next.js のサーバー機能がなくても、ブラウザだけで動く React アプリでそのまま使えます。

この記事では、Vite と React 19 だけで動く会員登録フォームを、useState 版 → useActionState 版 → useFormStatus 追加版 → useOptimistic 追加版の順に書き換えていきます。各版をブラウザで動かし、送信0.2秒後と完了後の画面の状態を記録した結果を載せています。送信後に入力欄が空に戻る挙動、値を残す2つの書き方、項目ごとのエラーメッセージの返し方も、同じフォームで確かめました。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / React 19.3.0・react-dom 19.3.0 / Vite 8.3.2(@vitejs/plugin-react 6.1.1)/ TypeScript 7.0.2 / Playwright 1.56.0 の Chromium 141 で開発サーバーの画面を操作。

3つのフックの早見表

まず、フォームまわりで使う3つのフックを一覧にします。「どこに置けるか」が3つで違うので、そこを先に押さえると迷いにくくなります。

フック import 元 引数 戻り値 置ける場所 用途
useActionState react (アクション関数, 初期state, permalink?) [state, アクションを起動する関数, isPending] form を描画するコンポーネント(form の外側でも内側でも可) 送信結果(メッセージ・エラー)と処理中フラグの管理
useFormStatus react-dom なし { pending, data, method, action } form の内側に描画される別コンポーネント 送信ボタンなどの部品から、親 form の送信状態を読む
useOptimistic react (元の値, reducer?) [楽観的な値, 更新関数] どのコンポーネントでも可。更新関数はアクションの中で呼ぶ 結果を待たずに画面へ先に反映し、完了時に本来の値へ戻す

アクション関数の形は、単独で form に渡す場合と useActionState に渡す場合で違います。

渡し先 アクション関数の引数 戻り値の扱い
form の action に直接 (formData) 使われない
useActionState の第1引数 (前回のstate, formData) 次の state になる

この記事で動かした結果の要点は次のとおりです。

確かめたこと 結果
action に関数を渡して送信が終わったあとの入力欄 入力欄が空に戻る(検証エラーを返したときも同じ)
値を残したいとき 返した値を defaultValue に渡す、または onSubmit から startTransition で起動する
useFormStatus を form と同じコンポーネントで呼ぶ pending は送信中も false のまま
useOptimistic で足した仮の行 完了すると本来の一覧に置き換わる。エラーを返すと仮の行は自動で消える
起動関数を startTransition の外で呼ぶ コンソールにメッセージが出て、isPending が true にならない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useActionStateとuseFormStatusの使い方」

準備:Vite+React 19だけの最小構成を作る

フォルダーを1つ作り、必要なパッケージを入れます。フレームワークは使いません。package.json の type を module にしておくと、vite.config.ts を ES モジュールの書き方のまま読み込めます。

mkdir form-demo && cd form-demo
npm init -y
npm pkg set type=module
npm i react react-dom
npm i -D vite @vitejs/plugin-react typescript @types/react @types/react-dom

置くファイルは次の5つと、各版のコンポーネントです。

form-demo/
├─ index.html
├─ vite.config.ts
├─ tsconfig.json
└─ src/
   ├─ main.tsx
   ├─ api.ts            … 保存処理を模した関数
   └─ UseStateForm.tsx  … 以降、版ごとにファイルを足す
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>form demo</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
// vite.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [react()],
})
{
  "compilerOptions": {
    "target": "es2023",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import UseStateForm from './UseStateForm'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <UseStateForm />
  </StrictMode>,
)

main.tsx の import を差し替えれば、以降の各版をそのまま試せます。起動は npx vite です。

保存処理を模した関数(全版で共通)

サーバーへの保存の代わりに、0.8秒待ってから結果を返す関数を用意します。結果の形(FormState)を最初に決めておくのが、useActionState を使うときのコツです。メッセージ、項目ごとのエラー、入力値の3つを1つのオブジェクトにまとめています。

// src/api.ts
export type Values = { name: string; email: string }
export type FieldErrors = { name?: string; email?: string }
export type FormState = {
  ok: boolean
  message: string
  errors: FieldErrors
  values: Values
}

export const initialState: FormState = {
  ok: false,
  message: '',
  errors: {},
  values: { name: '', email: '' },
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

// サーバーへの保存を模した関数(0.8秒かかる)
export async function saveMember(values: Values): Promise<FormState> {
  await sleep(800)
  const errors: FieldErrors = {}
  if (values.name.trim() === '') errors.name = '名前を入力してください'
  if (!values.email.includes('@')) errors.email = 'メールアドレスの形式で入力してください'
  if (errors.name || errors.email) {
    return { ok: false, message: '入力内容を確認してください', errors, values }
  }
  return {
    ok: true,
    message: `${values.name} さんを登録しました`,
    errors: {},
    values: { name: '', email: '' },
  }
}

第1版:useStateだけで書いたフォーム

比較の出発点として、従来どおり useState と onSubmit で書いた版です。入力値2つ、送信中フラグ、結果の4つの state を自分で管理しています。

// src/UseStateForm.tsx
import { useState } from 'react'
import type { FormEvent } from 'react'
import { saveMember, initialState } from './api'
import type { FormState } from './api'

export default function UseStateForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [pending, setPending] = useState(false)
  const [state, setState] = useState<FormState>(initialState)

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault()
    setPending(true)
    const result = await saveMember({ name, email })
    setState(result)
    if (result.ok) {
      setName('')
      setEmail('')
    }
    setPending(false)
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        名前
        <input name="name" value={name} onChange={(e) => setName(e.target.value)} />
      </label>
      {state.errors.name && <p className="error">{state.errors.name}</p>}
      <label>
        メール
        <input name="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      </label>
      {state.errors.email && <p className="error">{state.errors.email}</p>}
      <button type="submit" disabled={pending}>
        {pending ? '送信中…' : '登録'}
      </button>
      <p id="message">{state.message}</p>
    </form>
  )
}

名前に「田中」、メールに「tanaka」(@なし)と「tanaka@example.com」を入れて送信したときの、画面の状態です。以降の版も同じ入力で比べます。

== メール "tanaka"
  送信0.2秒後: ボタン「送信中…」(disabled) / 名前="田中" メール="tanaka"
  完了後     : ボタン「登録」 / 名前="田中" メール="tanaka"
               エラー: メールアドレスの形式で入力してください
               メッセージ: 入力内容を確認してください
== メール "tanaka@example.com"
  送信0.2秒後: ボタン「送信中…」(disabled) / 名前="田中" メール="tanaka@example.com"
  完了後     : ボタン「登録」 / 名前="" メール=""
               メッセージ: 田中 さんを登録しました

これで十分に動きます。ここから useActionState に置き換えると、送信中フラグと結果の state、preventDefault、入力値の state が不要になります。

第2版:useActionStateに置き換える

書き換えの手順は3つです。

  1. アクション関数を (前回のstate, formData) の形で書き、次の state を return する
  2. useActionState(アクション関数, 初期state) を呼び、[state, formAction, isPending] を受け取る
  3. form の onSubmit をやめ、action={formAction} を渡す
// src/ActionStateForm.tsx
import { useActionState } from 'react'
import { saveMember, initialState } from './api'
import type { FormState } from './api'

// 第1引数は「前回の state」、第2引数が FormData
async function registerAction(_prev: FormState, formData: FormData): Promise<FormState> {
  return saveMember({
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? ''),
  })
}

export default function ActionStateForm() {
  const [state, formAction, isPending] = useActionState(registerAction, initialState)

  return (
    <form action={formAction}>
      <label>
        名前
        <input name="name" />
      </label>
      {state.errors.name && <p className="error">{state.errors.name}</p>}
      <label>
        メール
        <input name="email" />
      </label>
      {state.errors.email && <p className="error">{state.errors.email}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '送信中…' : '登録'}
      </button>
      <p id="message">{state.message}</p>
    </form>
  )
}

入力欄から value と onChange が消え、name 属性だけになりました。値は送信時に FormData として渡されるので、formData.get('name') の名前と input の name 属性を一致させることだけ気をつけます。

実行結果は次のとおりです。

== メール "tanaka"
  送信0.2秒後: ボタン「送信中…」(disabled) / 名前="田中" メール="tanaka"
  完了後     : ボタン「登録」 / 名前="" メール=""
               エラー: メールアドレスの形式で入力してください
               メッセージ: 入力内容を確認してください
== メール "tanaka@example.com"
  送信0.2秒後: ボタン「送信中…」(disabled) / 名前="田中" メール="tanaka@example.com"
  完了後     : ボタン「登録」 / 名前="" メール=""
               メッセージ: 田中 さんを登録しました

送信中の表示とエラー表示は第1版と同じです。違いは1か所、検証エラーを返したときも入力欄が空に戻っている点です。これは React の form の仕様で、公式ドキュメントの form の項に「action の関数が成功すると、フォーム内の非制御の入力欄はすべてリセットされる」と書かれています。エラーの内容を state として return した場合も、関数自体は正常に終わっているのでリセットの対象になります。

useActionStateの引数と戻り値

位置 名前 内容
第1引数 アクション関数 (前回のstate, 渡された値) を受け取り、次の state を返す。async でも同期でもよい
第2引数 初期 state 最初の描画で使う値。アクションが一度動いたあとは使われない
第3引数(省略可) permalink サーバーコンポーネントで、JavaScript の読み込み前に送信された場合の移動先 URL。ブラウザだけで動くアプリでは指定しない
戻り値[0] state 最初は初期 state、以降はアクション関数が最後に返した値
戻り値[1] 起動関数 form の action や button の formAction に渡す。自分で呼ぶときは startTransition の中で呼ぶ
戻り値[2] isPending 起動したアクションが終わるまで true

公式ドキュメントには、続けて起動した場合の動きも書かれています。アクションは順番待ちの列に入り、前のものが終わってから次が動きます。次のアクションの第1引数には、前のアクションが返した state が渡されます。

アクション関数の第1引数を書き忘れたときの型エラー

form に直接渡すアクションは (formData)、useActionState に渡すアクションは (前回のstate, formData) です。この違いに気づかず第1引数を FormData にすると、TypeScript が教えてくれます。

// src/BadSignature.tsx
import { useActionState } from 'react'

// 第1引数に FormData を受け取るつもりで書いた例
async function registerAction(formData: FormData): Promise<string> {
  return String(formData.get('name'))
}

export default function BadSignature() {
  const [message, formAction] = useActionState(registerAction, '')
  return (
    <form action={formAction}>
      <input name="name" />
      <p>{message}</p>
    </form>
  )
}

npx tsc --noEmit の出力です。

src/BadSignature.tsx(9,48): error TS2769: No overload matches this call.
  The last overload gave the following error.
    Argument of type '(formData: FormData) => Promise<string>' is not assignable to parameter of type '(state: string, payload: unknown) => string | Promise<string>'.
      Types of parameters 'formData' and 'state' are incompatible.
        Type 'string' is not assignable to type 'FormData'.

(_prev: string, formData: FormData) と第1引数を足せば通ります。使わない引数は _prev のように下線で始めておくと意図が伝わります。

送信後に入力欄が空に戻る挙動と、値を残す書き方

第2版では、エラーのときも入力欄が空になりました。入力し直しになると使いにくいので、エラーのときは値を残し、成功のときだけ空にする形に直します。方法は2つあります。

方法 書き方 向いている場面
A. 返した値を defaultValue に渡す アクションが入力値を state に含めて返し、input に defaultValue={state.values.name} action={formAction} の形を保ちたいとき。まずこちら
B. onSubmit から起動する preventDefault() して startTransition の中で起動関数を呼ぶ 自動リセットそのものを使わず、入力欄を触らないでほしいとき

方法A:返した値をdefaultValueに渡す

api.ts の saveMember は、エラーのときは受け取った values をそのまま、成功のときは空の values を返すようにしてあります。input 側で受け取るだけです。

// src/KeepValuesForm.tsx
import { useActionState } from 'react'
import { saveMember, initialState } from './api'
import type { FormState } from './api'

async function registerAction(_prev: FormState, formData: FormData): Promise<FormState> {
  return saveMember({
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? ''),
  })
}

export default function KeepValuesForm() {
  const [state, formAction, isPending] = useActionState(registerAction, initialState)

  return (
    <form action={formAction}>
      <label>
        名前
        {/* 返ってきた値を defaultValue に渡すと、リセット後もその値が入る */}
        <input name="name" defaultValue={state.values.name} />
      </label>
      {state.errors.name && <p className="error">{state.errors.name}</p>}
      <label>
        メール
        <input name="email" defaultValue={state.values.email} />
      </label>
      {state.errors.email && <p className="error">{state.errors.email}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '送信中…' : '登録'}
      </button>
      <p id="message">{state.message}</p>
    </form>
  )
}
== メール "tanaka"
  完了後: 名前="田中" メール="tanaka"
          エラー: メールアドレスの形式で入力してください
== メール "tanaka@example.com"
  完了後: 名前="" メール=""
          メッセージ: 田中 さんを登録しました

エラーのときは値が残り、成功のときは空になりました。 フォームのリセットは「入力欄を初期値に戻す」動作なので、初期値(defaultValue)を返ってきた値にしておけば、戻る先がその値になるという仕組みです。

方法B:onSubmitからstartTransitionで起動する

action 属性を使わず、onSubmit の中で FormData を作って起動関数を呼びます。この形では自動リセットが働きません。

// src/NoResetForm.tsx
import { startTransition, useActionState } from 'react'
import type { FormEvent } from 'react'
import { saveMember, initialState } from './api'
import type { FormState } from './api'

async function registerAction(_prev: FormState, formData: FormData): Promise<FormState> {
  return saveMember({
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? ''),
  })
}

export default function NoResetForm() {
  const [state, dispatch, isPending] = useActionState(registerAction, initialState)

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const form = e.currentTarget
    const formData = new FormData(form)
    startTransition(async () => {
      await dispatch(formData)
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      {state.errors.name && <p className="error">{state.errors.name}</p>}
      <input name="email" />
      {state.errors.email && <p className="error">{state.errors.email}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? '送信中…' : '登録'}
      </button>
      <p id="message">{state.message}</p>
    </form>
  )
}
== メール "tanaka"
  送信0.2秒後: ボタン「送信中…」(disabled)
  完了後     : 名前="田中" メール="tanaka"
               エラー: メールアドレスの形式で入力してください
== メール "tanaka@example.com"
  完了後     : 名前="田中" メール="tanaka@example.com"
               メッセージ: 田中 さんを登録しました

成功したときも値が残ります。成功時だけ空にしたい場合は、onSubmit の中で結果を見て form.reset() を呼ぶ処理を足すか、方法Aを選びます。

4つの書き方での入力欄の違い(実行結果のまとめ)

書き方 エラーを返したあと 成功したあと
useState+onSubmit(第1版) 残る 自分で空にした
action={formAction}、defaultValue なし(第2版) 空に戻る 空に戻る
action={formAction}+defaultValue={state.values.…}(方法A) 残る 空に戻る
onSubmit+startTransition(方法B) 残る 残る

項目ごとのエラーメッセージを返す

項目ごとのエラーは、state の中に「項目名 → メッセージ」のオブジェクトを持たせて return するだけで表示できます。この記事の FormState では errors: { name?: string; email?: string } がそれに当たります。

名前を空、メールを「tanaka」にして送信した結果です(方法Aのフォーム)。

完了後: 名前="" メール="tanaka"
        エラー: 名前を入力してください
        エラー: メールアドレスの形式で入力してください
        メッセージ: 入力内容を確認してください

設計のポイントを表にまとめます。

ポイント 理由
検証エラーは throw せず、state として return する 公式ドキュメントでは、アクション関数が例外を投げると順番待ちのアクションが取り消され、近くの Error Boundary が表示されると説明されています。入力の不備は「想定内の結果」なので state で返します
成功時もエラー時も同じ形(同じ型)で返す JSX 側で state.errors.name && のように迷わず書ける
全体メッセージと項目別エラーを分ける 画面上部の案内と、入力欄の直下の案内を別々に出せる
入力値も一緒に返す 方法Aの defaultValue に使える

第3版:useFormStatusを追加して送信ボタンを部品にする

useActionState の isPending でも送信中の表示はできます。ただ、送信ボタンを共通部品にしたいとき、毎回 isPending を props で渡すのは手間です。useFormStatus を使うと、ボタン側が「自分を囲んでいる form は送信中か」を自分で調べられます。

// src/FormStatusForm.tsx
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { saveMember, initialState } from './api'
import type { FormState } from './api'

async function registerAction(_prev: FormState, formData: FormData): Promise<FormState> {
  return saveMember({
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? ''),
  })
}

// form の「内側に置かれる別コンポーネント」で useFormStatus を呼ぶ
function SubmitButton() {
  const { pending, data } = useFormStatus()
  return (
    <>
      <button type="submit" disabled={pending}>
        {pending ? '送信中…' : '登録'}
      </button>
      {pending && data && <p id="sending">{String(data.get('name'))} さんを送信しています</p>}
    </>
  )
}

export default function FormStatusForm() {
  const [state, formAction] = useActionState(registerAction, initialState)

  return (
    <form action={formAction}>
      <label>
        名前
        <input name="name" defaultValue={state.values.name} />
      </label>
      {state.errors.name && <p className="error">{state.errors.name}</p>}
      <label>
        メール
        <input name="email" defaultValue={state.values.email} />
      </label>
      {state.errors.email && <p className="error">{state.errors.email}</p>}
      <SubmitButton />
      <p id="message">{state.message}</p>
    </form>
  )
}

import 元が react ではなく react-dom である点に注意します。実行結果です。

== メール "tanaka@example.com"
  送信0.2秒後: ボタン「送信中…」(disabled)
               田中 さんを送信しています
  完了後     : ボタン「登録」 / 名前="" メール=""
               メッセージ: 田中 さんを登録しました

SubmitButton は props を1つも受け取っていませんが、送信中の表示に切り替わりました。data から送信中の FormData も読めるので、「田中 さんを送信しています」のような表示も部品の中だけで作れます。

useFormStatusが返す4つの値

プロパティ 型 内容
pending boolean 親の form が送信中なら true
data FormData または null 送信中のデータ。送信していないとき、親の form がないときは null
method 'get' または 'post' 親の form の送信方法
action 関数 または null 親の form の action に渡された関数。URL を渡した場合や action がない場合は null

うまく動かない例:formと同じコンポーネントで呼ぶとpendingがfalseのまま

useFormStatus でいちばん多いつまずきが、form を描画しているコンポーネント自身で呼んでしまうことです。再現してみます。useActionState の isPending と並べて表示します。

// src/SameComponentForm.tsx
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { saveMember, initialState } from './api'
import type { FormState } from './api'

async function registerAction(_prev: FormState, formData: FormData): Promise<FormState> {
  return saveMember({
    name: String(formData.get('name') ?? ''),
    email: String(formData.get('email') ?? ''),
  })
}

export default function SameComponentForm() {
  const [state, formAction, isPending] = useActionState(registerAction, initialState)
  // form を描画しているのと同じコンポーネントで呼んでいる
  const { pending } = useFormStatus()

  return (
    <form action={formAction}>
      <input name="name" />
      <input name="email" />
      <button type="submit" disabled={pending}>
        {pending ? '送信中…' : '登録'}
      </button>
      <p id="flags">
        useFormStatus の pending: {String(pending)} / useActionState の isPending: {String(isPending)}
      </p>
      <p id="message">{state.message}</p>
    </form>
  )
}
== メール "tanaka@example.com"
  送信0.2秒後: ボタン「登録」(押せる状態のまま)
               useFormStatus の pending: false / useActionState の isPending: true
  完了後     : ボタン「登録」
               useFormStatus の pending: false / useActionState の isPending: false
               メッセージ: 田中 さんを登録しました

送信中でも pending は false のままで、ボタンは切り替わりません。エラーも警告も出ないので、原因に気づきにくいところです。公式ドキュメントにも「useFormStatus は親の form の状態だけを返し、同じコンポーネントやその子で描画した form の状態は返さない」と書かれています。

状況 直し方
form と同じコンポーネントで useFormStatus を呼んでいる ボタン部分を別コンポーネントに切り出し、form の内側に置く(第3版の SubmitButton)
部品に切り出すほどでもない useActionState の isPending を使う
ボタンが form の外にある form の内側へ移すか、isPending を props で渡す

isPendingとuseFormStatusの使い分け

見たい場所 使うもの
form を描画しているコンポーネント自身 useActionState の isPending
form の内側に置く共通部品(送信ボタン、進行表示など) useFormStatus
送信中のデータの中身も表示に使いたい useFormStatus の data

第4版:useOptimisticを追加して結果を待たずに一覧へ反映する

最後に、登録した会員を一覧に表示する形にして、保存の完了を待たずに仮の行を出すようにします。これが楽観的更新です。

useOptimistic には「本来の値」を渡します。アクションが動いている間だけ、更新関数で足した内容を上乗せした値が返り、アクションが終わると本来の値に戻ります。

// src/OptimisticForm.tsx
import { useActionState, useOptimistic } from 'react'
import { useFormStatus } from 'react-dom'
import { saveMember } from './api'
import type { FieldErrors } from './api'

type Member = { name: string; email: string; sending?: boolean }
type ListState = { members: Member[]; message: string; errors: FieldErrors }

const initialList: ListState = { members: [], message: '', errors: {} }

function SubmitButton() {
  const { pending } = useFormStatus()
  return <button type="submit" disabled={pending}>{pending ? '送信中…' : '登録'}</button>
}

export default function OptimisticForm() {
  const [state, formAction] = useActionState(
    async (prev: ListState, formData: FormData): Promise<ListState> => {
      const values = {
        name: String(formData.get('name') ?? ''),
        email: String(formData.get('email') ?? ''),
      }
      // 結果を待たずに、一覧へ仮の行を足す
      addOptimistic({ ...values, sending: true })
      const result = await saveMember(values)
      if (!result.ok) {
        return { ...prev, message: result.message, errors: result.errors }
      }
      return { members: [...prev.members, values], message: result.message, errors: {} }
    },
    initialList,
  )

  const [optimisticMembers, addOptimistic] = useOptimistic(
    state.members,
    (current: Member[], added: Member) => [...current, added],
  )

  return (
    <>
      <form action={formAction}>
        <input name="name" placeholder="名前" />
        <input name="email" placeholder="メール" />
        <SubmitButton />
        {state.errors.name && <p className="error">{state.errors.name}</p>}
        {state.errors.email && <p className="error">{state.errors.email}</p>}
        <p id="message">{state.message}</p>
      </form>
      <ul>
        {optimisticMembers.map((m, i) => (
          <li key={i}>
            {m.name}({m.email}){m.sending && ' …送信中'}
          </li>
        ))}
      </ul>
    </>
  )
}

1回目に正しい入力(田中)、2回目にメールが「sato」の入力(佐藤)を送信した結果です。

== 1回目: 田中 / tanaka@example.com
  送信0.2秒後: 一覧 ["田中(tanaka@example.com) …送信中"]
  完了後     : 一覧 ["田中(tanaka@example.com)"]
               メッセージ: 田中 さんを登録しました
== 2回目: 佐藤 / sato
  送信0.2秒後: 一覧 ["田中(tanaka@example.com)", "佐藤(sato) …送信中"]
  完了後     : 一覧 ["田中(tanaka@example.com)"]
               エラー: メールアドレスの形式で入力してください
               メッセージ: 入力内容を確認してください

読み取れることは2つです。

  • 送信0.2秒後の時点で、もう一覧に行が出ている。 保存には0.8秒かかりますが、利用者は待たずに結果を見られます
  • 2回目はエラーを返したので、仮の行(佐藤)が自動で消えた。 取り消しの処理は書いていません。アクションが終わると useOptimistic は本来の値(state.members)に戻るため、members に足さなければ仮の行は残りません

useOptimisticの引数と戻り値

位置 内容
第1引数 本来の値。進行中のアクションがないときは、これがそのまま返る
第2引数(省略可) (現在の値, 更新関数に渡した値) から次の楽観的な値を作る関数。省略すると、更新関数に渡した値がそのまま楽観的な値になる
戻り値[0] 楽観的な値。アクション中は上乗せ後の値、それ以外は第1引数と同じ
戻り値[1] 更新関数。アクションの中(action に渡した関数や startTransition の中)で呼ぶ

公式ドキュメントでは、更新関数をアクションの外で呼ぶと「An optimistic state update occurred outside a Transition or Action. To fix, move the update to an Action, or wrap with startTransition.」という警告が出て、楽観的な値は一瞬だけ表示されると説明されています。上のコードのように、useActionState に渡したアクション関数の中で呼ぶ形にしておけば条件を満たせます。

楽観的更新が向く場面

場面 向き不向き
コメント投稿、いいね、チェックの切り替えなど、ほぼ成功する操作 向いている。体感が速くなる
結果がサーバーの計算で決まる操作(採番、在庫の引き当てなど) 仮の表示と結果がずれやすいので、送信中の表示にとどめる
取り消しがきかない操作(削除の確定など) 完了を待ってから反映するほうが伝わりやすい

起動関数を自分で呼ぶときはstartTransitionで包む

useActionState の戻り値の2番目(起動関数)は、form の action 以外からも呼べます。ボタンのクリックで呼ぶ例で、そのまま呼んだ場合と startTransition で包んだ場合を比べます。

// src/OutsideTransition.tsx
import { startTransition, useActionState } from 'react'

async function countUp(prev: number, step: number): Promise<number> {
  await new Promise((r) => setTimeout(r, 300))
  return prev + step
}

export default function OutsideTransition() {
  const [count, dispatch, isPending] = useActionState(countUp, 0)

  return (
    <>
      <p id="count">count: {count} / isPending: {String(isPending)}</p>
      {/* そのまま呼ぶ:開発ビルドでエラーメッセージが出る */}
      <button id="plain" onClick={() => dispatch(1)}>そのまま呼ぶ</button>
      {/* startTransition で包む */}
      <button id="wrapped" onClick={() => startTransition(() => dispatch(1))}>
        startTransition で包む
      </button>
    </>
  )
}
== 「そのまま呼ぶ」をクリック
  [console.error] An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an `action` or `formAction` prop.
  0.1秒後: count: 0 / isPending: false
  完了後 : count: 1 / isPending: false
== 「startTransition で包む」をクリック
  0.1秒後: count: 1 / isPending: true
  完了後 : count: 2 / isPending: false

そのまま呼んでも count は増えますが、処理中でも isPending が false のままで、コンソールにメッセージが出ます。startTransition で包むと isPending が true になり、メッセージも出ません。form の action や button の formAction に渡した場合は、React が自動でトランジションとして扱うので、包む必要はありません。

この例のように、第2引数は FormData に限りません。数値やオブジェクトを渡せば、フォーム以外の「結果と処理中フラグを持つ非同期処理」にも使えます。

つまずきやすい点と直し方の一覧

症状 原因 直し方
型エラー TS2769 が出る アクション関数の第1引数を FormData だと思っている (prev, formData) の順に直す
送信後に入力欄が空になる action に関数を渡した form の自動リセット 返した値を defaultValue に渡す、または onSubmit+startTransition
useFormStatus の pending がずっと false form と同じコンポーネントで呼んでいる、または form の外にある form の内側に置く別コンポーネントへ切り出す
isPending が true にならない、コンソールにメッセージが出る 起動関数をトランジションの外で呼んでいる startTransition(() => dispatch(...)) で包む
楽観的な表示が完了後に消える アクションが返す本来の値に反映していない アクションの戻り値(本来の state)にも同じ内容を足す
method="get" を書いても効かない action に関数を渡すと、method の指定にかかわらず POST 扱いになる(公式ドキュメントの form の注意点) action に関数を渡す形では method を書かない

useStateで書くか、useActionStateで書くかの判断表

条件 選ぶもの
送信して結果(成功・エラー)を表示する、一般的なフォーム useActionState+action={formAction}
入力のたびに検証したい、入力に応じて他の欄を変えたい 入力値は useState(制御コンポーネント)で持ち、送信部分だけ useActionState
送信ボタンを共通部品にしたい useFormStatus を部品の中で使う
結果を待たずに一覧へ反映したい useOptimistic を足す
項目が多く、検証ルールが複雑 フォーム用ライブラリの利用も検討する

1つの form に送信ボタンが複数ある場合(「下書き保存」と「公開」など)は、button の formAction に別の関数を渡すと、form の action を上書きできます(公式ドキュメントの form の項)。

よくある質問

Q. useActionState は Next.js がないと使えませんか? A. 使えます。この記事のコードはすべて、Vite と React 19.3.0 だけで動かしました。アクション関数はブラウザの中で動く普通の async 関数でかまいません。その中で fetch を使って自分の API を呼べば、どのサーバー構成でも使えます。

Q. アクション関数の中で例外が起きたらどうなりますか? A. 公式ドキュメントでは、順番待ちのアクションが取り消され、近くの Error Boundary が表示されると説明されています。入力の不備のように想定できる結果は、例外にせず state として return し、画面にメッセージを出す形にします。

Q. 送信後に入力欄を空にしたくないときはどうしますか? A. アクション関数が入力値を state に含めて返し、input の defaultValue に渡します。エラーのときは値が残り、成功のときに空の値を返せば空になります。自動リセット自体を使わない場合は、onSubmit で preventDefault() し、startTransition の中で起動関数を呼びます。

Q. useFormStatus と useActionState の isPending は、どちらを使えばよいですか? A. form を描画しているコンポーネントの中なら isPending、form の内側に置く共通部品なら useFormStatus です。useFormStatus は form と同じコンポーネントで呼ぶと false のままになるので、迷ったら isPending から始めると確実です。

まとめ

  • useActionState は [state, 起動関数, isPending] を返す。 アクション関数は (前回のstate, formData) を受け取り、次の state を return する
  • form の action={formAction} に渡すと、preventDefault も送信中フラグの state も不要になる
  • 送信が終わると入力欄は空に戻る(エラーを返したときも)。 値を残すには、返した値を defaultValue に渡す
  • 項目ごとのエラーは throw せず、state の中のオブジェクトとして return する
  • useFormStatus は react-dom から import し、form の内側に置く別コンポーネントで呼ぶ。 同じコンポーネントで呼ぶと pending は false のまま
  • useOptimistic の更新関数はアクションの中で呼ぶ。 エラーを返せば仮の表示は自動で消える
  • 起動関数を自分で呼ぶときは startTransition で包む

参考・出典

  • React 公式ドキュメント「useActionState」 https://react.dev/reference/react/useActionState
  • React 公式ドキュメント「useFormStatus」 https://react.dev/reference/react-dom/hooks/useFormStatus
  • React 公式ドキュメント「useOptimistic」 https://react.dev/reference/react/useOptimistic
  • React 公式ドキュメント「form」 https://react.dev/reference/react-dom/components/form

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React useActionStateとuseFormStatusの使い方」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?