最終更新:

React 19 Form Actions完全ガイド: useActionStateとuseFormStatusの活用


React 19では、フォーム処理を大幅に簡素化する新しいAPIが導入されました。本記事では、Form ActionsとuseActionState、useFormStatusフックを使った実践的なフォーム実装方法を解説します。

React 19以前のフォーム処理

従来のReactでは、フォームの状態管理、バリデーション、送信処理、エラーハンドリングをすべて手動で実装する必要がありました。

// React 18以前の典型的なフォーム実装
import { useState, FormEvent } from 'react'

function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setIsLoading(true)
    setError(null)

    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })

      if (!response.ok) {
        throw new Error('Login failed')
      }

      const data = await response.json()
      // ログイン成功処理
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        disabled={isLoading}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        disabled={isLoading}
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Loading...' : 'Login'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  )
}

この実装には多くのボイラープレートが含まれています。

React 19のForm Actions

React 19では、action propを使ってフォーム送信を簡潔に処理できます。

// React 19のForm Actions
import { useActionState } from 'react'

type LoginState = {
  error?: string
  success?: boolean
}

async function loginAction(
  prevState: LoginState,
  formData: FormData
): Promise<LoginState> {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  try {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })

    if (!response.ok) {
      return { error: 'Login failed' }
    }

    return { success: true }
  } catch (error) {
    return { error: 'An error occurred' }
  }
}

function LoginForm() {
  const [state, formAction, isPending] = useActionState(loginAction, {})

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Loading...' : 'Login'}
      </button>
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">Login successful!</p>}
    </form>
  )
}

主な改善点:

  • 状態管理のボイラープレートが大幅に削減
  • isPendingが自動的に管理される
  • FormDataを直接扱える
  • 非制御コンポーネントとして実装可能

useActionStateの詳細

useActionStateは、非同期アクションの状態を管理するフックです。

import { useActionState } from 'react'

const [state, formAction, isPending] = useActionState(
  actionFunction,
  initialState,
  permalink? // オプション: Server Actionsで使用
)

実践例: ユーザー登録フォーム

import { useActionState } from 'react'
import { z } from 'zod'

// バリデーションスキーマ
const registerSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
})

type RegisterState = {
  errors?: {
    username?: string[]
    email?: string[]
    password?: string[]
    confirmPassword?: string[]
    _form?: string[]
  }
  success?: boolean
}

async function registerAction(
  prevState: RegisterState,
  formData: FormData
): Promise<RegisterState> {
  // バリデーション
  const validationResult = registerSchema.safeParse({
    username: formData.get('username'),
    email: formData.get('email'),
    password: formData.get('password'),
    confirmPassword: formData.get('confirmPassword'),
  })

  if (!validationResult.success) {
    return {
      errors: validationResult.error.flatten().fieldErrors,
    }
  }

  // API呼び出し
  try {
    const response = await fetch('/api/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(validationResult.data),
    })

    if (!response.ok) {
      const error = await response.json()
      return {
        errors: {
          _form: [error.message || 'Registration failed'],
        },
      }
    }

    return { success: true }
  } catch (error) {
    return {
      errors: {
        _form: ['An unexpected error occurred'],
      },
    }
  }
}

function RegisterForm() {
  const [state, formAction, isPending] = useActionState(registerAction, {})

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="username">Username</label>
        <input
          id="username"
          name="username"
          type="text"
          required
          disabled={isPending}
        />
        {state.errors?.username && (
          <p className="text-red-500">{state.errors.username[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          disabled={isPending}
        />
        {state.errors?.email && (
          <p className="text-red-500">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          disabled={isPending}
        />
        {state.errors?.password && (
          <p className="text-red-500">{state.errors.password[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input
          id="confirmPassword"
          name="confirmPassword"
          type="password"
          required
          disabled={isPending}
        />
        {state.errors?.confirmPassword && (
          <p className="text-red-500">{state.errors.confirmPassword[0]}</p>
        )}
      </div>

      {state.errors?._form && (
        <div className="bg-red-50 p-4 rounded">
          {state.errors._form.map((error, i) => (
            <p key={i} className="text-red-500">{error}</p>
          ))}
        </div>
      )}

      {state.success && (
        <div className="bg-green-50 p-4 rounded">
          <p className="text-green-500">Registration successful!</p>
        </div>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-blue-500 text-white py-2 rounded disabled:bg-gray-300"
      >
        {isPending ? 'Registering...' : 'Register'}
      </button>
    </form>
  )
}

useFormStatusフック

useFormStatusは、親フォームの送信状態を取得できるフックです。

import { useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending, data, method, action } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  )
}

function MyForm() {
  return (
    <form action={myAction}>
      <input name="email" type="email" />
      <SubmitButton />
    </form>
  )
}

実践例: 楽観的UI更新

import { useActionState, useOptimistic } from 'react'
import { useFormStatus } from 'react-dom'

type Comment = {
  id: string
  text: string
  author: string
  createdAt: string
}

type CommentState = {
  error?: string
}

async function addCommentAction(
  prevState: CommentState,
  formData: FormData
): Promise<CommentState> {
  const text = formData.get('text') as string
  const author = formData.get('author') as string

  try {
    const response = await fetch('/api/comments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, author }),
    })

    if (!response.ok) {
      return { error: 'Failed to add comment' }
    }

    return {}
  } catch (error) {
    return { error: 'An error occurred' }
  }
}

function CommentButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Adding...' : 'Add Comment'}
    </button>
  )
}

function CommentsSection({ initialComments }: { initialComments: Comment[] }) {
  const [comments, setComments] = useState(initialComments)
  const [state, formAction] = useActionState(addCommentAction, {})
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newComment: Comment) => [...state, newComment]
  )

  return (
    <div>
      <ul>
        {optimisticComments.map((comment) => (
          <li key={comment.id} className={comment.id.startsWith('temp-') ? 'opacity-50' : ''}>
            <p>{comment.text}</p>
            <small>{comment.author} - {comment.createdAt}</small>
          </li>
        ))}
      </ul>

      <form
        action={async (formData) => {
          // 楽観的更新
          addOptimisticComment({
            id: `temp-${Date.now()}`,
            text: formData.get('text') as string,
            author: formData.get('author') as string,
            createdAt: new Date().toISOString(),
          })

          // 実際のアクション実行
          await formAction(formData)

          // 成功時はフォームをリセット
          if (!state.error) {
            ;(document.querySelector('form') as HTMLFormElement)?.reset()
          }
        }}
      >
        <input name="author" placeholder="Your name" required />
        <textarea name="text" placeholder="Your comment" required />
        <CommentButton />
        {state.error && <p className="error">{state.error}</p>}
      </form>
    </div>
  )
}

高度なパターン

1. 複数ステップフォーム

import { useActionState } from 'react'

type Step = 'personal' | 'address' | 'payment' | 'confirm'

type FormState = {
  step: Step
  data: {
    personal?: { name: string; email: string }
    address?: { street: string; city: string; zip: string }
    payment?: { cardNumber: string; expiry: string }
  }
  errors?: Record<string, string[]>
}

async function multiStepAction(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const action = formData.get('_action') as string

  if (action === 'next') {
    const currentStep = prevState.step

    // 現在のステップのデータを保存
    if (currentStep === 'personal') {
      return {
        ...prevState,
        step: 'address',
        data: {
          ...prevState.data,
          personal: {
            name: formData.get('name') as string,
            email: formData.get('email') as string,
          },
        },
      }
    }

    if (currentStep === 'address') {
      return {
        ...prevState,
        step: 'payment',
        data: {
          ...prevState.data,
          address: {
            street: formData.get('street') as string,
            city: formData.get('city') as string,
            zip: formData.get('zip') as string,
          },
        },
      }
    }

    if (currentStep === 'payment') {
      return {
        ...prevState,
        step: 'confirm',
        data: {
          ...prevState.data,
          payment: {
            cardNumber: formData.get('cardNumber') as string,
            expiry: formData.get('expiry') as string,
          },
        },
      }
    }
  }

  if (action === 'back') {
    const steps: Step[] = ['personal', 'address', 'payment', 'confirm']
    const currentIndex = steps.indexOf(prevState.step)
    return {
      ...prevState,
      step: steps[Math.max(0, currentIndex - 1)],
    }
  }

  if (action === 'submit') {
    // 最終送信処理
    try {
      await fetch('/api/submit', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(prevState.data),
      })

      return {
        ...prevState,
        step: 'personal', // リセット
        data: {},
      }
    } catch (error) {
      return {
        ...prevState,
        errors: { _form: ['Submission failed'] },
      }
    }
  }

  return prevState
}

function MultiStepForm() {
  const [state, formAction, isPending] = useActionState(multiStepAction, {
    step: 'personal',
    data: {},
  })

  return (
    <form action={formAction}>
      {state.step === 'personal' && (
        <>
          <h2>Personal Information</h2>
          <input name="name" defaultValue={state.data.personal?.name} required />
          <input name="email" type="email" defaultValue={state.data.personal?.email} required />
          <button type="submit" name="_action" value="next">Next</button>
        </>
      )}

      {state.step === 'address' && (
        <>
          <h2>Address</h2>
          <input name="street" defaultValue={state.data.address?.street} required />
          <input name="city" defaultValue={state.data.address?.city} required />
          <input name="zip" defaultValue={state.data.address?.zip} required />
          <button type="submit" name="_action" value="back">Back</button>
          <button type="submit" name="_action" value="next">Next</button>
        </>
      )}

      {state.step === 'payment' && (
        <>
          <h2>Payment</h2>
          <input name="cardNumber" required />
          <input name="expiry" placeholder="MM/YY" required />
          <button type="submit" name="_action" value="back">Back</button>
          <button type="submit" name="_action" value="next">Next</button>
        </>
      )}

      {state.step === 'confirm' && (
        <>
          <h2>Confirm</h2>
          <pre>{JSON.stringify(state.data, null, 2)}</pre>
          <button type="submit" name="_action" value="back">Back</button>
          <button type="submit" name="_action" value="submit" disabled={isPending}>
            {isPending ? 'Submitting...' : 'Submit'}
          </button>
        </>
      )}

      {state.errors?._form && (
        <p className="error">{state.errors._form[0]}</p>
      )}
    </form>
  )
}

2. リアルタイムバリデーション

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useEffect, useState } from 'react'

async function checkUsernameAvailability(username: string): Promise<boolean> {
  const response = await fetch(`/api/check-username?username=${username}`)
  const data = await response.json()
  return data.available
}

function UsernameInput() {
  const [username, setUsername] = useState('')
  const [isChecking, setIsChecking] = useState(false)
  const [isAvailable, setIsAvailable] = useState<boolean | null>(null)
  const { pending } = useFormStatus()

  useEffect(() => {
    if (username.length < 3) {
      setIsAvailable(null)
      return
    }

    const timer = setTimeout(async () => {
      setIsChecking(true)
      const available = await checkUsernameAvailability(username)
      setIsAvailable(available)
      setIsChecking(false)
    }, 500)

    return () => clearTimeout(timer)
  }, [username])

  return (
    <div>
      <input
        name="username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        disabled={pending}
      />
      {isChecking && <span>Checking...</span>}
      {isAvailable === true && <span className="text-green-500">✓ Available</span>}
      {isAvailable === false && <span className="text-red-500">✗ Taken</span>}
    </div>
  )
}

まとめ

React 19のForm Actionsは、フォーム実装を大幅に簡素化します。

主な利点:

  • ボイラープレートの大幅削減
  • 非制御コンポーネントによるパフォーマンス向上
  • FormDataの直接的な扱い
  • isPendingの自動管理
  • Server Actionsとのシームレスな統合

適用シーン:

  • ログイン・登録フォーム
  • データ送信フォーム
  • 複数ステップフォーム
  • 楽観的UI更新が必要な場合

従来の状態管理アプローチと比較して、よりシンプルで保守性の高いコードを書けます。