はじめに
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向上 |