BLOG

ブログ

2026/03/23 技術系

【React】React 19でフォーム実装がシンプルになった話

この記事を書いた人 Y.S

はじめに

以前の記事「Zustandを使ってみた」で、ECサイト環境にZustandを導入しました。

そのECサイト環境で、React 19の Server Action を試してみたのでご紹介します。

Server Actionとは、ファイルの先頭に use server ディレクティブを付けることで、その関数がサーバー上で実行されるようになる仕組みです。

React 18では実験的機能でしたが、React 19で安定版として正式採用されました。

従来のようにAPIルートを自分で実装せずに、DB操作やクッキー設定を行えるようになるとのことで、試してみようと思います。

また、Server Actionと組み合わせて使う useActionState というフックもReact 19で新しく追加されました。フォームの状態管理で useStateを複数行で記載していたものを1行に置き換えることができます。

その様子を実際のコードを示しながら Before / After で比較していきます。

環境

  • React 19.1.0
  • Next.js 15.5.2
  • Zustand 5.0.8

Before: 従来の書き方

// src/app/login/page.tsx(Before)
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/auth'

export default function LoginPage() {
  const router = useRouter()
  const login = useAuthStore((state) => state.login)
  const [email, setEmail] = useState('')       // ① メール
  const [password, setPassword] = useState('') // ② パスワード
  const [error, setError] = useState('')       // ③ エラー
  const [loading, setLoading] = useState(false) // ④ ローディング

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault() // フォーム送信時にページ全体がリロードされるのを防ぐ
    setError('')
    setLoading(true) // ローディング表示

    const result = await login(email, password) // ログイン処理を実行

    if (result.success) {
      router.push('/')
    } else {
      setError(result.error || 'ログインに失敗しました')
    }

    setLoading(false) // フォーム送信完了後、ローディングを非表示
  }

  // ログインフォーム
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {error && <p>{error}</p>}
      <button disabled={loading}>
        {loading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

状況整理

  • useState を4つ使用(email, password, error, loading)
  • e.preventDefault() でフォーム送信時にページ全体がリロードされるのを防ぐ
  • setLoading(true) / setLoading(false) を手動で管理
  • Zustand経由でlogin APIルートを呼び出す

After: Server Action + useActionState で書き換える

1. Server Action を作成する

use server ディレクティブを付けた関数を作成します。この関数はサーバー上で実行されるため、DB操作やクッキー設定を直接行えます。

// src/actions/auth.ts
'use server' // ディレクティブ

import bcrypt from 'bcryptjs'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'

export type LoginState = {
  error: string | null
}

// サーバーで実行される関数
export async function loginAction(
  prevState: LoginState,
  formData: FormData
): Promise<LoginState> {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  // バリデーションチェック
  if (!email || !password) {
    return { error: 'メールアドレスとパスワードを入力してください' }
  }

  // ユーザー検索
  const user = await prisma.user.findUnique({
    where: { email },
  })

  if (!user) {
    return { error: 'メールアドレスまたはパスワードが正しくありません' }
  }

  // パスワードチェック
  const passwordValid = await bcrypt.compare(password, user.password)
  if (!passwordValid) {
    return { error: 'メールアドレスまたはパスワードが正しくありません' }
  }

  const { password: _, ...userWithoutPassword } = user

  // サーバー側で直接クッキーを設定
  const cookieStore = await cookies()
  cookieStore.set('user', JSON.stringify(userWithoutPassword), {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  })

  redirect('/')  // サーバー側でリダイレクト
}

2. useActionState でフォームと繋ぐ

useActionState の仕様として、フォーム処理に必要な3つの要素を返すように設計されています。

先程作成した Server Action を useActionState に渡すと、[state, formAction, isPending] の3つが返ってきます。

state : Server Action の実行結果(エラーメッセージなど)。

formAction : <form action={formAction}> にセットする関数。

isPending : 送信中かどうか。

formAction を <form action={formAction}> にセットすることで、フォーム送信時に自動的に Server Action が実行され、結果が state に返り、送信中は isPendingtrue になります。

// src/app/login/page.tsx(After)
'use client'

import { useActionState } from 'react'
import { loginAction, type LoginState } from '@/actions/auth'

const initialState: LoginState = { error: null }

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

  // ログインフォーム
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      {state.error && <p>{state.error}</p>}
      <button disabled={isPending}>
        {isPending ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

今回の変更点

BeforeAfter
useState 4個useActionState 1個
onSubmit={handleSubmit}action={formAction}
Controlled Input(value + onChangeUncontrolled Input(`name`属性のみ)
手動で setLoading(true/false)isPending が自動管理
e.preventDefault() が必要不要
Zustand → fetch → APIルート → レスポンスServer Actionで直接DB操作

ポイント

  • useActionState[state, formAction, isPending] の3つを返す
  • state にはServer Actionの返り値(エラー情報など)が入る
  • isPending はフォーム送信中に自動で true になる
  • <form action={formAction}> でServer Action と連携する

まとめ

useActionState を使用することで useState の数が減り、e.preventDefault() や手動のローディング管理が不要になりました。また、Server Actionによって APIルートを自分で書かずにDB操作やクッキー設定ができるようになり、フォーム周りの実装がすっきりしたような感じがしました。

今回はログインフォームというシンプルな例でしたが、Server Action と useActionState の基本的な使い方を掴むことできたので、今後は他のフォームにも適用していきたいなと思います。

今回の変更でZustandの実装を一部削除しましたが、画面をまたいで共有する状態(ユーザー情報やカートなど)には引き続きZustandを使うように考えています。

参考リンク


株式会社ウイングドアは福岡のシステム開発会社です。
現在、私達と一緒に"楽しく仕事が出来る仲間"として、新卒・中途採用を絶賛募集しています!
ウイングドアの仲間達となら楽しく仕事できるかも?と興味をもった方、
お気軽にお問い合わせ下さい!

アーカイブ