メインコンテンツへスキップ
#Web制作 約8分で読めます

Next.js 15 Server Actions でフォーム送信エラーハンドリングを実装する【型安全なバリデーション】

Next.js 15 Server Actionsにおけるフォーム送信エラーハンドリングの実装方法を解説。zodを活用した型安全なバリデーションとuseActionStateによる状態管理の実践ガイド

Next.js 15のServer Actionsは、サーバーサイドでフォーム処理を実行できる強力な機能ですが、エラーハンドリングとバリデーションの実装方法が従来のAPIルートと大きく異なります。特にフォーム送信時のエラー処理を適切に実装しないと、ユーザー体験が著しく低下します。

この記事では、Next.js 15のServer Actionsにおける型安全なバリデーションユーザーフレンドリーなエラーハンドリングの実装方法を、実践的なコード例とともに解説します。

Server Actionsにおけるエラーハンドリングの基本

Next.js 15のServer Actionsでは、従来のtry-catchによるエラーハンドリングに加えて、フォーム状態の管理バリデーションエラーの返却が重要になります。

基本的なServer Actionの構造

Server Actionsは'use server'ディレクティブを使用して定義します。

'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string

  // データベース処理
  try {
    await db.user.create({
      data: { name, email }
    })
    revalidatePath('/users')
    redirect('/users')
  } catch (error) {
    // エラーハンドリング
    throw new Error('ユーザー作成に失敗しました')
  }
}

この基本実装には以下の問題があります:

  • バリデーションエラーが考慮されていない
  • エラーメッセージがユーザーに表示されない
  • 型安全性が保証されていない
  • フォームの入力値が保持されない

zodを活用した型安全なバリデーション実装

zodを使用することで、実行時のバリデーションTypeScript型の生成を同時に実現できます。

zodスキーマの定義

// app/actions/user.schema.ts
import { z } from 'zod'

export const createUserSchema = z.object({
  name: z.string()
    .min(2, { message: '名前は2文字以上で入力してください' })
    .max(50, { message: '名前は50文字以内で入力してください' }),
  email: z.string()
    .email({ message: '有効なメールアドレスを入力してください' }),
  age: z.coerce.number()
    .int({ message: '整数を入力してください' })
    .min(18, { message: '18歳以上である必要があります' })
    .max(120, { message: '年齢は120歳以内で入力してください' })
    .optional(),
})

export type CreateUserInput = z.infer<typeof createUserSchema>

バリデーション結果を返却するServer Action

エラー情報をクライアントに返却するため、Server Actionの戻り値の型を明示的に定義します。

// app/actions/user.action.ts
'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { createUserSchema } from './user.schema'

type ActionState = {
  success: boolean
  errors?: {
    name?: string[]
    email?: string[]
    age?: string[]
    _form?: string[]
  }
  data?: {
    name: string
    email: string
    age?: number
  }
}

export async function createUser(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // FormDataをオブジェクトに変換
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    age: formData.get('age'),
  }

  // zodでバリデーション
  const result = createUserSchema.safeParse(rawData)

  if (!result.success) {
    // バリデーションエラーをフラット化して返却
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
      data: rawData as any,
    }
  }

  // データベース処理
  try {
    await db.user.create({
      data: result.data
    })
    revalidatePath('/users')
  } catch (error) {
    return {
      success: false,
      errors: {
        _form: ['ユーザー作成に失敗しました。時間をおいて再度お試しください。']
      },
      data: result.data,
    }
  }

  redirect('/users')
}

このパターンのポイント:

  • safeParse()を使用してバリデーション失敗時に例外を投げない
  • エラー情報をfieldErrorsとして構造化して返却
  • データベースエラーは_formフィールドに格納
  • 入力値をdataとして返却し、フォームの再表示で使用可能にする

useActionState フックによる状態管理

React 19で追加されたuseActionState(旧useFormState)を使用して、Server Actionの実行状態とエラー情報を管理します。

// app/users/new/page.tsx
'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions/user.action'

const initialState = {
  success: false,
  errors: {},
  data: undefined,
}

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

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          名前
        </label>
        <input
          type="text"
          id="name"
          name="name"
          defaultValue={state.data?.name}
          className="mt-1 block w-full rounded-md border-gray-300"
          aria-invalid={!!state.errors?.name}
          aria-describedby={state.errors?.name ? 'name-error' : undefined}
        />
        {state.errors?.name && (
          <p id="name-error" className="mt-1 text-sm text-red-600">
            {state.errors.name.join(', ')}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          メールアドレス
        </label>
        <input
          type="email"
          id="email"
          name="email"
          defaultValue={state.data?.email}
          className="mt-1 block w-full rounded-md border-gray-300"
          aria-invalid={!!state.errors?.email}
          aria-describedby={state.errors?.email ? 'email-error' : undefined}
        />
        {state.errors?.email && (
          <p id="email-error" className="mt-1 text-sm text-red-600">
            {state.errors.email.join(', ')}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="age" className="block text-sm font-medium">
          年齢(任意)
        </label>
        <input
          type="number"
          id="age"
          name="age"
          defaultValue={state.data?.age}
          className="mt-1 block w-full rounded-md border-gray-300"
          aria-invalid={!!state.errors?.age}
          aria-describedby={state.errors?.age ? 'age-error' : undefined}
        />
        {state.errors?.age && (
          <p id="age-error" className="mt-1 text-sm text-red-600">
            {state.errors.age.join(', ')}
          </p>
        )}
      </div>

      {state.errors?._form && (
        <div className="rounded-md bg-red-50 p-4">
          <p className="text-sm text-red-800">
            {state.errors._form.join(', ')}
          </p>
        </div>
      )}

      <button
        type="submit"
        disabled={isPending}
        className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isPending ? '送信中...' : '送信'}
      </button>
    </form>
  )
}

useActionStateの重要なポイント

  • 第1引数: Server Action関数(createUser
  • 第2引数: 初期状態(initialState
  • 戻り値: [state, formAction, isPending]
    • state: Server Actionの実行結果
    • formAction: フォームのaction属性に渡す関数
    • isPending: 実行中かどうかのboolean

defaultValueで入力値を保持し、aria-invalidaria-describedbyでアクセシビリティを確保しています。

楽観的UI更新とエラーリカバリー

フォーム送信時のユーザー体験を向上させるため、useOptimisticフックを組み合わせて楽観的UI更新を実装できます。

'use client'

import { useActionState, useOptimistic, startTransition } from 'react'
import { createUser } from '@/app/actions/user.action'
import type { User } from '@prisma/client'

type Props = {
  users: User[]
}

export default function UserList({ users }: Props) {
  const [optimisticUsers, addOptimisticUser] = useOptimistic(
    users,
    (state, newUser: User) => [...state, newUser]
  )

  const [state, formAction, isPending] = useActionState(
    createUser,
    { success: false, errors: {} }
  )

  async function handleSubmit(formData: FormData) {
    const newUser = {
      id: crypto.randomUUID(),
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      createdAt: new Date(),
    }

    startTransition(() => {
      addOptimisticUser(newUser)
    })

    await formAction(formData)
  }

  return (
    <div>
      <ul className="space-y-2">
        {optimisticUsers.map((user) => (
          <li key={user.id} className="p-4 border rounded">
            {user.name} ({user.email})
          </li>
        ))}
      </ul>

      <form action={handleSubmit} className="mt-4 space-y-4">
        {/* フォームフィールド */}
      </form>
    </div>
  )
}

このパターンでは、フォーム送信と同時にUIを更新し、エラーが発生した場合に自動的にロールバックされます。

フォームバリデーションのフローチャート

Next.js 15 Server Actionsにおけるフォーム送信からエラーハンドリングまでの処理フローを図解します。

flowchart TD
    A["フォーム送信"] --> B["useActionState 実行"]
    B --> C["Server Action 呼び出し"]
    C --> D["FormData 取得"]
    D --> E["zodバリデーション<br/>safeParse()"]
    E --> F{バリデーション成功?}
    F -->|失敗| G["エラー情報を返却<br/>fieldErrors"]
    G --> H["クライアント側で<br/>エラー表示"]
    H --> I["入力値を保持<br/>defaultValue"]
    F -->|成功| J["データベース処理"]
    J --> K{DB処理成功?}
    K -->|失敗| L["_form エラー返却"]
    L --> H
    K -->|成功| M["revalidatePath実行"]
    M --> N["redirect実行"]

この図は、Server Actionsにおけるエラーハンドリングの全体像を示しています。特にバリデーションエラーとデータベースエラーを分離して処理する点が重要です。

複数ステップフォームのエラーハンドリング

複数ステップのフォームでは、各ステップのバリデーション状態を管理する必要があります。

// app/actions/multi-step-form.action.ts
'use server'

import { z } from 'zod'

const step1Schema = z.object({
  email: z.string().email(),
})

const step2Schema = z.object({
  name: z.string().min(2),
  age: z.coerce.number().int().min(18),
})

const step3Schema = z.object({
  address: z.string().min(10),
  phone: z.string().regex(/^\d{10,11}$/),
})

type MultiStepState = {
  currentStep: number
  errors?: Record<string, string[]>
  data: {
    email?: string
    name?: string
    age?: number
    address?: string
    phone?: string
  }
}

export async function processMultiStepForm(
  prevState: MultiStepState,
  formData: FormData
): Promise<MultiStepState> {
  const action = formData.get('action') as string
  const currentStep = parseInt(formData.get('currentStep') as string)

  if (action === 'next') {
    const rawData = Object.fromEntries(formData.entries())
    let result

    switch (currentStep) {
      case 1:
        result = step1Schema.safeParse(rawData)
        break
      case 2:
        result = step2Schema.safeParse(rawData)
        break
      case 3:
        result = step3Schema.safeParse(rawData)
        break
      default:
        return prevState
    }

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

    // 最終ステップの場合はデータベース保存
    if (currentStep === 3) {
      try {
        await db.user.create({
          data: {
            ...prevState.data,
            ...result.data,
          }
        })
      } catch (error) {
        return {
          ...prevState,
          errors: { _form: ['保存に失敗しました'] }
        }
      }
    }

    return {
      currentStep: currentStep + 1,
      errors: undefined,
      data: {
        ...prevState.data,
        ...result.data,
      }
    }
  }

  if (action === 'back') {
    return {
      ...prevState,
      currentStep: Math.max(1, currentStep - 1),
      errors: undefined,
    }
  }

  return prevState
}
// app/form/page.tsx
'use client'

import { useActionState } from 'react'
import { processMultiStepForm } from '@/app/actions/multi-step-form.action'

const initialState = {
  currentStep: 1,
  errors: undefined,
  data: {},
}

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

  return (
    <form action={formAction}>
      <input type="hidden" name="currentStep" value={state.currentStep} />
      
      {state.currentStep === 1 && (
        <div>
          <h2>ステップ1: メールアドレス</h2>
          <input
            name="email"
            type="email"
            defaultValue={state.data.email}
          />
          {state.errors?.email && (
            <p className="text-red-600">{state.errors.email.join(', ')}</p>
          )}
          <button type="submit" name="action" value="next" disabled={isPending}>
            次へ
          </button>
        </div>
      )}

      {state.currentStep === 2 && (
        <div>
          <h2>ステップ2: 基本情報</h2>
          <input name="name" defaultValue={state.data.name} />
          {state.errors?.name && (
            <p className="text-red-600">{state.errors.name.join(', ')}</p>
          )}
          <input name="age" type="number" defaultValue={state.data.age} />
          {state.errors?.age && (
            <p className="text-red-600">{state.errors.age.join(', ')}</p>
          )}
          <button type="submit" name="action" value="back">
            戻る
          </button>
          <button type="submit" name="action" value="next" disabled={isPending}>
            次へ
          </button>
        </div>
      )}

      {state.currentStep === 3 && (
        <div>
          <h2>ステップ3: 連絡先情報</h2>
          <input name="address" defaultValue={state.data.address} />
          {state.errors?.address && (
            <p className="text-red-600">{state.errors.address.join(', ')}</p>
          )}
          <input name="phone" defaultValue={state.data.phone} />
          {state.errors?.phone && (
            <p className="text-red-600">{state.errors.phone.join(', ')}</p>
          )}
          <button type="submit" name="action" value="back">
            戻る
          </button>
          <button type="submit" name="action" value="next" disabled={isPending}>
            送信
          </button>
        </div>
      )}
    </form>
  )
}

このパターンでは、各ステップのバリデーションスキーマを分離し、進行状況をcurrentStepで管理しています。

ファイルアップロードのエラーハンドリング

Server Actionsでファイルアップロードを扱う場合、ファイルサイズ・MIME タイプのバリデーションが必要です。

// app/actions/upload.schema.ts
import { z } from 'zod'

const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']

export const uploadImageSchema = z.object({
  title: z.string().min(1, { message: 'タイトルは必須です' }),
  image: z
    .custom<File>()
    .refine((file) => file instanceof File && file.size > 0, {
      message: '画像ファイルを選択してください',
    })
    .refine((file) => file.size <= MAX_FILE_SIZE, {
      message: 'ファイルサイズは5MB以下にしてください',
    })
    .refine((file) => ACCEPTED_IMAGE_TYPES.includes(file.type), {
      message: 'JPEG、PNG、WebP形式のみ対応しています',
    }),
})
// app/actions/upload.action.ts
'use server'

import { uploadImageSchema } from './upload.schema'
import { writeFile } from 'fs/promises'
import { join } from 'path'

type UploadState = {
  success: boolean
  errors?: Record<string, string[]>
  data?: {
    title?: string
    imageUrl?: string
  }
}

export async function uploadImage(
  prevState: UploadState,
  formData: FormData
): Promise<UploadState> {
  const rawData = {
    title: formData.get('title'),
    image: formData.get('image'),
  }

  const result = uploadImageSchema.safeParse(rawData)

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

  try {
    const file = result.data.image
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)

    const filename = `${Date.now()}-${file.name}`
    const path = join(process.cwd(), 'public', 'uploads', filename)
    
    await writeFile(path, buffer)

    return {
      success: true,
      data: {
        title: result.data.title,
        imageUrl: `/uploads/${filename}`,
      },
    }
  } catch (error) {
    return {
      success: false,
      errors: {
        _form: ['ファイルのアップロードに失敗しました'],
      },
    }
  }
}

ファイルバリデーションはz.custom<File>()でFile型を扱い、refine()でサイズとMIMEタイプをチェックしています。

共通のエラーハンドリングユーティリティ

複数のServer Actionsで共通のエラーハンドリングロジックを使い回すためのユーティリティ関数を作成します。

// app/lib/action-utils.ts
import { z } from 'zod'

export type ActionState<TData = any> = {
  success: boolean
  errors?: Record<string, string[]>
  data?: TData
}

export async function withValidation<TInput, TOutput>(
  schema: z.ZodSchema<TInput>,
  formData: FormData,
  handler: (data: TInput) => Promise<TOutput>
): Promise<ActionState<TOutput>> {
  const rawData = Object.fromEntries(formData.entries())
  const result = schema.safeParse(rawData)

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

  try {
    const data = await handler(result.data)
    return {
      success: true,
      data,
    }
  } catch (error) {
    return {
      success: false,
      errors: {
        _form: [
          error instanceof Error
            ? error.message
            : '処理中にエラーが発生しました'
        ],
      },
    }
  }
}
// app/actions/product.action.ts
'use server'

import { withValidation } from '@/app/lib/action-utils'
import { createProductSchema } from './product.schema'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createProduct(prevState: any, formData: FormData) {
  const result = await withValidation(
    createProductSchema,
    formData,
    async (data) => {
      const product = await db.product.create({ data })
      revalidatePath('/products')
      return product
    }
  )

  if (result.success) {
    redirect('/products')
  }

  return result
}

このユーティリティ関数により、バリデーションとエラーハンドリングのボイラープレートコードを大幅に削減できます。

まとめ

Next.js 15のServer Actionsにおけるフォーム送信エラーハンドリングの実装ポイント:

  • zodによる型安全なバリデーション: safeParse()でエラー情報を構造化して返却
  • useActionStateによる状態管理: フォーム状態とエラー情報をReactの状態として管理
  • エラー情報の構造化: フィールドごとのエラー(fieldErrors)とフォーム全体のエラー(_form)を分離
  • 入力値の保持: defaultValueでバリデーションエラー後も入力値を保持
  • アクセシビリティの確保: aria-invalidaria-describedbyでスクリーンリーダー対応
  • 楽観的UI更新: useOptimisticでUX向上、エラー時は自動ロールバック
  • 複数ステップフォーム: ステップごとのバリデーションスキーマを分離
  • ファイルアップロード: サイズ・MIME タイプのバリデーションをz.custom<File>()で実装
  • 共通ユーティリティ: withValidationでボイラープレートコードを削減

型安全性とユーザー体験を両立したフォームバリデーションを実装することで、保守性が高く堅牢なアプリケーションを構築できます。

参考リンク

#Next.js #Server Actions #React #TypeScript #フォームバリデーション
シェア