Next.js 14/15のServer Actionsで「やらかさない」ための極意:バリデーションと状態管理の現在地

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`で体験を磨け:ストリーミングやネットワークラグによる「チラつき」は、楽観的更新で解決する。
Next.js 15では、このあたりのキャッシュ制御や`useActionState`の挙動がさらに洗練されている。結局のところ、泥臭く「フレームワークが何を意図してこのフックを用意したのか」を理解することこそが、最速でプロダクトを完成させる唯一の道だ。

さあ、明日からはもう、バリデーションエラーで画面を真っ白にするのは終わりにしようぜ。