Next.js 14/15のServer Actionsで「やらかさない」ための極意:バリデーションと状態管理の現在地
Next.js 14でServer Actionsに飛びついた結果、本番環境でユーザーの入力データが消し飛ぶという悪夢を見た。正直、あの時の冷や汗は忘れられない。Reactの`useFormState`を過信して、バリデーションと状態管理を適当にマージしたのが運の尽きだった。
多くのエンジニアが「APIルートを書かなくていい!最強!」と浮かれるが、App Routerの裏側にある「Reactのライフサイクル」を理解していないと、必ず足元をすくわれる。今日は、俺が地獄を見た末にたどり着いた「負けないための実装パターン」を共有する。
1. なぜ「throw」によるエラーハンドリングは地獄を見るのか
初心者がやりがちな最大の過ちが、バリデーションエラーを`throw`で投げることだ。
// ❌ やってはいけない実装
if (!result.success) {
throw new Error('不正な入力です') // これが地獄の始まり
}
なぜこれがダメなのか?理由はシンプルだ。Next.jsの`error.tsx`(ErrorBoundary)が発火してしまうからだ。
Server Actionsで`throw`すると、Next.jsはそれを「予期せぬサーバー側のエラー」と見なす。結果として、UIはエラー画面にフォールバックし、ユーザーは入力中だったフォームから強制的に追い出される。バリデーションエラーは「制御可能な業務例外」であり、システムエラーではない。HTTPステータスコードで言えば400番台を返すのが正解であり、500番台を返すべきではないのだ。
2. 状態の二重管理:なぜ「怪奇現象」が起きるのか
俺がかつて陥った「入力した文字が消える」「エラーが表示されたりされなかったりする」という怪奇現象。これはReactのレンダリングライフサイクルと、Server Actionsの非同期性のズレから来る。
`useState`でフォームの状態を管理しつつ、別途`useEffect`でServer Actionsを呼び出すと、ReactのレンダリングパスとServer Actionsの完了タイミングが分離する。結果として、古い状態でUIが更新されたり、Server Actionsが完了する前に再レンダリングが走ったりして、状態が「二重管理」の状態に陥る。
これを解決する唯一の解が、`useActionState`(旧`useFormState`)だ。
改善された実装パターン
Zodでバリデーションを行い、結果を「結果オブジェクト」として返す。これが最も堅牢だ。
// actions/user.ts
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('正しいメールアドレスを入力しろよ'),
age: z.coerce.number().min(18, '18歳未満はNGだ'),
})
export type FormState = {
success: boolean
message: string
errors?: Record<string, string[]>
}
export async function registerUser(prevState: FormState, formData: FormData): Promise<FormState> {
const validatedFields = schema.safeParse(Object.fromEntries(formData))
if (!validatedFields.success) {
return {
success: false,
message: 'バリデーションエラーだよ',
errors: validatedFields.error.flatten().fieldErrors,
}
}
// ここでDB処理
return { success: true, message: '登録完了!' }
}
3. クライアント側の正解:useActionState + useOptimistic
大規模なフォームで一番怖いのは、サーバー通信中の「ラグ」だ。ここで`useOptimistic`を組み合わせるのが、今のモダンなフロントエンドの流儀だ。
'use client'
import { useActionState, useOptimistic } from 'react'
import { registerUser } from '@/actions/user'
export default function RegisterForm() {
const [state, action, isPending] = useActionState(registerUser, { success: false, message: '' })
// 送信中のUIを最適化する
const [optimisticState, addOptimistic] = useOptimistic(state, (curr, payload) => ({
...curr,
message: '送信中...'
}))
return (
<form action={async (formData) => {
addOptimistic(null) // 即座にUIを更新
await action(formData)
}}>
<input name="email" type="text" />
{state.errors?.email && <p className="text-red-500">{state.errors.email[0]}</p>}
<button disabled={isPending}>登録</button>
<p>{optimisticState.message}</p>
</form>
)
}
まとめ:フレームワークに「寄り添う」という技術
Server Actionsは楽をするための魔法の杖ではなく、型安全を維持するための強力な武器だ。
- `throw`は使うな:例外はErrorBoundaryを壊す。結果オブジェクトを返せ。
- `useActionState`を使え:状態管理をフレームワークに委譲することで、同期ズレは根絶できる。
- `useOptimistic`で体験を磨け:ストリーミングやネットワークラグによる「チラつき」は、楽観的更新で解決する。
さあ、明日からはもう、バリデーションエラーで画面を真っ白にするのは終わりにしようぜ。