ブログ
【React】React 19でフォーム実装がシンプルになった話
目次
はじめに
以前の記事「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 に返り、送信中は isPending が true になります。
// 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>
)
}今回の変更点
| Before | After |
useState 4個 | useActionState 1個 |
onSubmit={handleSubmit} | action={formAction} |
Controlled Input(value + onChange) | Uncontrolled 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を使うように考えています。
参考リンク
株式会社ウイングドアは福岡のシステム開発会社です。
現在、私達と一緒に"楽しく仕事が出来る仲間"として、新卒・中途採用を絶賛募集しています!
ウイングドアの仲間達となら楽しく仕事できるかも?と興味をもった方、
お気軽にお問い合わせ下さい!