Documentation Next.js

はじめに

Next.js App Routerでは、Server Actionsを使ったフォーム処理が推奨されています。React Hook FormやZodと組み合わせることで、型安全で使いやすいフォームを構築できます。

Server Actions基本パターン

シンプルなフォーム

// app/contact/page.tsx
import { submitContact } from '@/actions/contact';

export default function ContactPage() {
  return (
    <form action={submitContact} className="space-y-4 max-w-md mx-auto">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          名前
        </label>
        <input
          type="text"
          id="name"
          name="name"
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          メールアドレス
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">
          メッセージ
        </label>
        <textarea
          id="message"
          name="message"
          rows={4}
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>

      <button
        type="submit"
        className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700"
      >
        送信
      </button>
    </form>
  );
}

Server Action

// actions/contact.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const contactSchema = z.object({
  name: z.string().min(1, '名前は必須です').max(50, '名前は50文字以内です'),
  email: z.string().email('有効なメールアドレスを入力してください'),
  message: z.string().min(10, 'メッセージは10文字以上で入力してください'),
});

export async function submitContact(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const result = contactSchema.safeParse(rawData);

  if (!result.success) {
    throw new Error('バリデーションエラー');
  }

  // データベースに保存
  await db.contact.create({
    data: result.data,
  });

  revalidatePath('/contact');
}

useActionStateでのエラー表示

型定義

// types/form.ts
export interface FormState<T = unknown> {
  success: boolean;
  data?: T;
  errors?: {
    [K in keyof T]?: string[];
  };
  message?: string;
}

Server Action with State

// actions/register.ts
'use server';

import { z } from 'zod';
import { type FormState } from '@/types/form';

const registerSchema = z.object({
  username: z
    .string()
    .min(3, 'ユーザー名は3文字以上')
    .max(20, 'ユーザー名は20文字以内')
    .regex(/^[a-zA-Z0-9_]+$/, '英数字とアンダースコアのみ使用可能'),
  email: z.string().email('有効なメールアドレスを入力'),
  password: z
    .string()
    .min(8, 'パスワードは8文字以上')
    .regex(/[A-Z]/, '大文字を含めてください')
    .regex(/[0-9]/, '数字を含めてください'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'パスワードが一致しません',
  path: ['confirmPassword'],
});

type RegisterInput = z.infer<typeof registerSchema>;

export async function registerUser(
  prevState: FormState<RegisterInput>,
  formData: FormData
): Promise<FormState<RegisterInput>> {
  const rawData = {
    username: formData.get('username') as string,
    email: formData.get('email') as string,
    password: formData.get('password') as string,
    confirmPassword: formData.get('confirmPassword') as string,
  };

  const result = registerSchema.safeParse(rawData);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors as FormState<RegisterInput>['errors'],
    };
  }

  try {
    // ユーザー作成
    await createUser(result.data);

    return {
      success: true,
      message: '登録が完了しました',
    };
  } catch (error) {
    return {
      success: false,
      message: 'ユーザー登録に失敗しました',
    };
  }
}

フォームコンポーネント

// components/RegisterForm.tsx
'use client';

import { useActionState } from 'react';
import { registerUser } from '@/actions/register';
import { type FormState } from '@/types/form';

const initialState: FormState = { success: false };

export function RegisterForm() {
  const [state, formAction, isPending] = useActionState(registerUser, initialState);

  return (
    <form action={formAction} className="space-y-6 max-w-md mx-auto">
      {state.message && (
        <div
          className={`p-4 rounded-md ${
            state.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
          }`}
        >
          {state.message}
        </div>
      )}

      <FormField
        label="ユーザー名"
        name="username"
        type="text"
        errors={state.errors?.username}
      />

      <FormField
        label="メールアドレス"
        name="email"
        type="email"
        errors={state.errors?.email}
      />

      <FormField
        label="パスワード"
        name="password"
        type="password"
        errors={state.errors?.password}
      />

      <FormField
        label="パスワード(確認)"
        name="confirmPassword"
        type="password"
        errors={state.errors?.confirmPassword}
      />

      <button
        type="submit"
        disabled={isPending}
        className="w-full py-3 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? '登録中...' : '登録する'}
      </button>
    </form>
  );
}

interface FormFieldProps {
  label: string;
  name: string;
  type: string;
  errors?: string[];
}

function FormField({ label, name, type, errors }: FormFieldProps) {
  return (
    <div>
      <label htmlFor={name} className="block text-sm font-medium text-gray-700">
        {label}
      </label>
      <input
        type={type}
        id={name}
        name={name}
        className={`mt-1 block w-full rounded-md shadow-sm ${
          errors?.length ? 'border-red-500' : 'border-gray-300'
        }`}
      />
      {errors?.map((error, i) => (
        <p key={i} className="mt-1 text-sm text-red-600">
          {error}
        </p>
      ))}
    </div>
  );
}

React Hook Form + Server Actions

インストール

npm install react-hook-form @hookform/resolvers zod

統合フォーム

// components/ProfileForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTransition } from 'react';
import { updateProfile } from '@/actions/profile';

const profileSchema = z.object({
  displayName: z.string().min(1, '表示名は必須です').max(50),
  bio: z.string().max(200, '自己紹介は200文字以内').optional(),
  website: z.string().url('有効なURLを入力').optional().or(z.literal('')),
  location: z.string().max(100).optional(),
});

type ProfileFormData = z.infer<typeof profileSchema>;

interface Props {
  defaultValues?: Partial<ProfileFormData>;
}

export function ProfileForm({ defaultValues }: Props) {
  const [isPending, startTransition] = useTransition();

  const {
    register,
    handleSubmit,
    formState: { errors },
    setError,
  } = useForm<ProfileFormData>({
    resolver: zodResolver(profileSchema),
    defaultValues,
  });

  const onSubmit = handleSubmit((data) => {
    startTransition(async () => {
      const result = await updateProfile(data);

      if (!result.success && result.errors) {
        // サーバーエラーをフォームに反映
        Object.entries(result.errors).forEach(([field, messages]) => {
          if (messages?.[0]) {
            setError(field as keyof ProfileFormData, {
              message: messages[0],
            });
          }
        });
      }
    });
  });

  return (
    <form onSubmit={onSubmit} className="space-y-6">
      <div>
        <label htmlFor="displayName" className="block text-sm font-medium">
          表示名
        </label>
        <input
          {...register('displayName')}
          id="displayName"
          className="mt-1 block w-full rounded-md border-gray-300"
        />
        {errors.displayName && (
          <p className="mt-1 text-sm text-red-600">{errors.displayName.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="bio" className="block text-sm font-medium">
          自己紹介
        </label>
        <textarea
          {...register('bio')}
          id="bio"
          rows={3}
          className="mt-1 block w-full rounded-md border-gray-300"
        />
        {errors.bio && (
          <p className="mt-1 text-sm text-red-600">{errors.bio.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="website" className="block text-sm font-medium">
          ウェブサイト
        </label>
        <input
          {...register('website')}
          id="website"
          type="url"
          placeholder="https://example.com"
          className="mt-1 block w-full rounded-md border-gray-300"
        />
        {errors.website && (
          <p className="mt-1 text-sm text-red-600">{errors.website.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? '保存中...' : '保存'}
      </button>
    </form>
  );
}

ファイルアップロード

フォームコンポーネント

// components/AvatarUpload.tsx
'use client';

import { useActionState, useRef, useState } from 'react';
import Image from 'next/image';
import { uploadAvatar } from '@/actions/avatar';
import { type FormState } from '@/types/form';

const initialState: FormState = { success: false };

export function AvatarUpload({ currentAvatar }: { currentAvatar?: string }) {
  const [preview, setPreview] = useState<string | null>(currentAvatar ?? null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [state, formAction, isPending] = useActionState(uploadAvatar, initialState);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      const reader = new FileReader();
      reader.onload = () => setPreview(reader.result as string);
      reader.readAsDataURL(file);
    }
  };

  return (
    <form action={formAction} className="space-y-4">
      <div className="flex items-center gap-6">
        <div className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-200">
          {preview ? (
            <Image src={preview} alt="Avatar" fill className="object-cover" />
          ) : (
            <div className="flex items-center justify-center h-full text-gray-400">
              <svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
                <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
              </svg>
            </div>
          )}
        </div>

        <div>
          <input
            ref={fileInputRef}
            type="file"
            name="avatar"
            accept="image/jpeg,image/png,image/webp"
            onChange={handleFileChange}
            className="hidden"
          />
          <button
            type="button"
            onClick={() => fileInputRef.current?.click()}
            className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
          >
            画像を選択
          </button>
          <p className="mt-2 text-sm text-gray-500">
            JPEG、PNG、WebP(最大2MB)
          </p>
        </div>
      </div>

      {state.errors?.avatar && (
        <p className="text-sm text-red-600">{state.errors.avatar[0]}</p>
      )}

      {preview && preview !== currentAvatar && (
        <button
          type="submit"
          disabled={isPending}
          className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
        >
          {isPending ? 'アップロード中...' : 'アップロード'}
        </button>
      )}
    </form>
  );
}

Server Action

// actions/avatar.ts
'use server';

import { revalidatePath } from 'next/cache';
import { type FormState } from '@/types/form';

const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

export async function uploadAvatar(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const file = formData.get('avatar') as File | null;

  if (!file || file.size === 0) {
    return {
      success: false,
      errors: { avatar: ['ファイルを選択してください'] },
    };
  }

  if (!ALLOWED_TYPES.includes(file.type)) {
    return {
      success: false,
      errors: { avatar: ['JPEG、PNG、WebP形式のみ対応しています'] },
    };
  }

  if (file.size > MAX_FILE_SIZE) {
    return {
      success: false,
      errors: { avatar: ['ファイルサイズは2MB以下にしてください'] },
    };
  }

  try {
    // ファイルをアップロード(S3、Cloudflare R2など)
    const buffer = await file.arrayBuffer();
    const url = await uploadToStorage(buffer, file.type);

    // データベースを更新
    await updateUserAvatar(url);

    revalidatePath('/settings/profile');

    return {
      success: true,
      message: 'アバターを更新しました',
    };
  } catch (error) {
    return {
      success: false,
      message: 'アップロードに失敗しました',
    };
  }
}

動的フォーム(配列フィールド)

// components/TagsForm.tsx
'use client';

import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const tagsSchema = z.object({
  tags: z.array(
    z.object({
      name: z.string().min(1, 'タグ名は必須').max(30),
    })
  ).min(1, '最低1つのタグが必要').max(10, 'タグは10個まで'),
});

type TagsFormData = z.infer<typeof tagsSchema>;

export function TagsForm({ defaultTags = [] }: { defaultTags?: string[] }) {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<TagsFormData>({
    resolver: zodResolver(tagsSchema),
    defaultValues: {
      tags: defaultTags.map((name) => ({ name })),
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'tags',
  });

  const onSubmit = handleSubmit(async (data) => {
    console.log(data.tags.map((t) => t.name));
  });

  return (
    <form onSubmit={onSubmit} className="space-y-4">
      {fields.map((field, index) => (
        <div key={field.id} className="flex gap-2">
          <input
            {...register(`tags.${index}.name`)}
            placeholder="タグ名"
            className="flex-1 rounded-md border-gray-300"
          />
          <button
            type="button"
            onClick={() => remove(index)}
            className="px-3 py-2 text-red-600 hover:bg-red-50 rounded"
          >
            削除
          </button>
        </div>
      ))}

      {errors.tags?.root && (
        <p className="text-sm text-red-600">{errors.tags.root.message}</p>
      )}

      {fields.length < 10 && (
        <button
          type="button"
          onClick={() => append({ name: '' })}
          className="text-blue-600 hover:text-blue-800"
        >
          + タグを追加
        </button>
      )}

      <button
        type="submit"
        className="px-6 py-2 bg-blue-600 text-white rounded-md"
      >
        保存
      </button>
    </form>
  );
}

Optimistic Updates

// components/LikeButton.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/actions/like';

interface Props {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
}

export function LikeButton({ postId, initialLiked, initialCount }: Props) {
  const [isPending, startTransition] = useTransition();
  const [optimisticState, setOptimisticState] = useOptimistic(
    { liked: initialLiked, count: initialCount },
    (state, newLiked: boolean) => ({
      liked: newLiked,
      count: newLiked ? state.count + 1 : state.count - 1,
    })
  );

  const handleClick = () => {
    startTransition(async () => {
      setOptimisticState(!optimisticState.liked);
      await toggleLike(postId);
    });
  };

  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition ${
        optimisticState.liked
          ? 'bg-red-100 text-red-600'
          : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
      }`}
    >
      <svg className="w-5 h-5" fill={optimisticState.liked ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
      </svg>
      <span>{optimisticState.count}</span>
    </button>
  );
}

まとめ

パターン用途利点
Server Actions単体シンプルなフォームProgressive Enhancement
useActionStateエラー表示が必要状態管理が簡単
React Hook Form複雑なバリデーションリアルタイム検証
useOptimistic即座のフィードバックUX向上

参考文献

円