ConformでモダンなReactフォームバリデーション入門
Conformを使ったProgressiveEnhancementなフォームバリデーションの実装方法を実例とともに詳しく解説します。
約5分で読めます
技術記事
実践的
この記事のポイント
Conformを使ったProgressiveEnhancementなフォームバリデーションの実装方法を実例とともに詳しく解説します。
この記事では、実践的なアプローチで技術的な課題を解決する方法を詳しく解説します。具体的なコード例とともに、ベストプラクティスを学ぶことができます。
Conformは、Progressive Enhancementの原則に従って設計されたReact向けフォームライブラリです。型安全性、アクセシビリティ、パフォーマンスを重視し、ZodやYupなどのバリデーションライブラリとシームレスに統合できます。
Conformとは
Conformは以下の特徴を持つフォームライブラリです:
- Progressive Enhancement: JavaScriptが無効でも動作する基本機能
- 型安全性: TypeScriptとの完全な統合
- ゼロ依存: コアライブラリは外部依存なし
- 柔軟なバリデーション: Zod、Yup、任意のライブラリと連携可能
- アクセシビリティ: ARIA属性の自動管理
基本的なセットアップ
インストール
npm install @conform-to/react @conform-to/zod
# または
yarn add @conform-to/react @conform-to/zod
ZodとConformを組み合わせる場合:
npm install zod
基本的な使用法
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
// Zodスキーマ定義
const schema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
});
function LoginForm() {
const [form, fields] = useForm({
// バリデーション時の処理
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
// サブミット時の処理
onSubmit(event, { formData }) {
event.preventDefault();
const submission = parseWithZod(formData, { schema });
if (submission.status === 'success') {
console.log('Form data:', submission.value);
// ログイン処理を実行
}
},
});
return (
<form {...form.props}>
<div>
<label htmlFor={fields.email.id}>メールアドレス</label>
<input
{...fields.email.props}
type="email"
placeholder="your-email@example.com"
/>
<div id={fields.email.errorId}>
{fields.email.errors}
</div>
</div>
<div>
<label htmlFor={fields.password.id}>パスワード</label>
<input
{...fields.password.props}
type="password"
placeholder="8文字以上のパスワード"
/>
<div id={fields.password.errorId}>
{fields.password.errors}
</div>
</div>
<button type="submit">ログイン</button>
</form>
);
}
実践的なフォーム例
ユーザー登録フォーム
import { useState } from 'react';
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
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])(?=.*[A-Z])(?=.*\d)/, '大文字、小文字、数字を含む必要があります'),
confirmPassword: z.string(),
birthDate: z.string().refine((date) => {
const age = new Date().getFullYear() - new Date(date).getFullYear();
return age >= 18;
}, '18歳以上である必要があります'),
newsletter: z.boolean().optional(),
terms: z.boolean().refine((val) => val === true, '利用規約に同意してください'),
}).refine((data) => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'],
});
function RegisterForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema: registerSchema });
},
async onSubmit(event, { formData }) {
event.preventDefault();
setIsSubmitting(true);
const submission = parseWithZod(formData, { schema: registerSchema });
if (submission.status === 'success') {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submission.value),
});
if (response.ok) {
alert('登録が完了しました!');
} else {
alert('登録に失敗しました。');
}
} catch (error) {
alert('ネットワークエラーが発生しました。');
}
}
setIsSubmitting(false);
},
});
return (
<form {...form.props} className="space-y-6">
<div>
<label htmlFor={fields.username.id} className="block text-sm font-medium">
ユーザー名 *
</label>
<input
{...fields.username.props}
type="text"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
disabled={isSubmitting}
/>
<div className="mt-2 text-sm text-red-600">
{fields.username.errors}
</div>
</div>
<div>
<label htmlFor={fields.email.id} className="block text-sm font-medium">
メールアドレス *
</label>
<input
{...fields.email.props}
type="email"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
disabled={isSubmitting}
/>
<div className="mt-2 text-sm text-red-600">
{fields.email.errors}
</div>
</div>
<div>
<label htmlFor={fields.password.id} className="block text-sm font-medium">
パスワード *
</label>
<input
{...fields.password.props}
type="password"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
disabled={isSubmitting}
/>
<div className="mt-2 text-sm text-red-600">
{fields.password.errors}
</div>
</div>
<div>
<label htmlFor={fields.confirmPassword.id} className="block text-sm font-medium">
パスワード確認 *
</label>
<input
{...fields.confirmPassword.props}
type="password"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
disabled={isSubmitting}
/>
<div className="mt-2 text-sm text-red-600">
{fields.confirmPassword.errors}
</div>
</div>
<div>
<label htmlFor={fields.birthDate.id} className="block text-sm font-medium">
生年月日 *
</label>
<input
{...fields.birthDate.props}
type="date"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
disabled={isSubmitting}
/>
<div className="mt-2 text-sm text-red-600">
{fields.birthDate.errors}
</div>
</div>
<div className="flex items-center">
<input
{...fields.newsletter.props}
type="checkbox"
className="h-4 w-4 text-blue-600 rounded"
disabled={isSubmitting}
/>
<label htmlFor={fields.newsletter.id} className="ml-2 block text-sm">
ニュースレターを受け取る
</label>
</div>
<div className="flex items-center">
<input
{...fields.terms.props}
type="checkbox"
className="h-4 w-4 text-blue-600 rounded"
disabled={isSubmitting}
/>
<label htmlFor={fields.terms.id} className="ml-2 block text-sm">
利用規約に同意する *
</label>
</div>
<div className="text-sm text-red-600">
{fields.terms.errors}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? '登録中...' : '登録する'}
</button>
</form>
);
}
動的フォーム(配列フィールド)
import { useFieldList } from '@conform-to/react';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(1, '名前を入力してください'),
contacts: z.array(z.object({
type: z.enum(['email', 'phone', 'address']),
value: z.string().min(1, '値を入力してください'),
})).min(1, '最低1つの連絡先を入力してください'),
});
function ContactForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema: contactSchema });
},
onSubmit(event, { formData }) {
event.preventDefault();
const submission = parseWithZod(formData, { schema: contactSchema });
if (submission.status === 'success') {
console.log('Contacts:', submission.value);
}
},
});
const contacts = useFieldList(form.ref, fields.contacts);
return (
<form {...form.props} className="space-y-6">
<div>
<label htmlFor={fields.name.id}>名前</label>
<input {...fields.name.props} type="text" />
<div>{fields.name.errors}</div>
</div>
<fieldset>
<legend>連絡先</legend>
{contacts.map((contact, index) => (
<div key={contact.key} className="border p-4 rounded">
<div className="grid grid-cols-3 gap-4">
<div>
<label htmlFor={contact.fields.type.id}>種類</label>
<select {...contact.fields.type.props}>
<option value="">選択してください</option>
<option value="email">メール</option>
<option value="phone">電話</option>
<option value="address">住所</option>
</select>
<div>{contact.fields.type.errors}</div>
</div>
<div>
<label htmlFor={contact.fields.value.id}>値</label>
<input {...contact.fields.value.props} type="text" />
<div>{contact.fields.value.errors}</div>
</div>
<div className="flex items-end">
<button
{...form.remove.getButtonProps({
name: fields.contacts.name,
index,
})}
type="button"
className="bg-red-500 text-white px-3 py-1 rounded"
>
削除
</button>
</div>
</div>
</div>
))}
<button
{...form.insert.getButtonProps({
name: fields.contacts.name,
})}
type="button"
className="bg-green-500 text-white px-4 py-2 rounded"
>
連絡先を追加
</button>
<div>{fields.contacts.errors}</div>
</fieldset>
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
送信
</button>
</form>
);
}
ファイルアップロード
import { z } from 'zod';
const uploadSchema = z.object({
title: z.string().min(1, 'タイトルを入力してください'),
description: z.string().optional(),
files: z.array(z.instanceof(File))
.min(1, '最低1つのファイルを選択してください')
.refine((files) => files.every(file => file.size <= 5 * 1024 * 1024), {
message: 'ファイルサイズは5MB以下にしてください',
}),
});
function FileUploadForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, {
schema: uploadSchema,
// ファイルを配列として扱う
async: true,
});
},
async onSubmit(event, { formData }) {
event.preventDefault();
const submission = parseWithZod(formData, { schema: uploadSchema });
if (submission.status === 'success') {
const uploadFormData = new FormData();
uploadFormData.append('title', submission.value.title);
if (submission.value.description) {
uploadFormData.append('description', submission.value.description);
}
submission.value.files.forEach((file, index) => {
uploadFormData.append(`file-${index}`, file);
});
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData,
});
if (response.ok) {
alert('アップロード完了!');
}
} catch (error) {
alert('アップロードに失敗しました');
}
}
},
});
return (
<form {...form.props} encType="multipart/form-data" className="space-y-4">
<div>
<label htmlFor={fields.title.id}>タイトル</label>
<input {...fields.title.props} type="text" />
<div>{fields.title.errors}</div>
</div>
<div>
<label htmlFor={fields.description.id}>説明</label>
<textarea {...fields.description.props} rows={3} />
<div>{fields.description.errors}</div>
</div>
<div>
<label htmlFor={fields.files.id}>ファイル</label>
<input
{...fields.files.props}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx"
/>
<div>{fields.files.errors}</div>
</div>
<button type="submit">アップロード</button>
</form>
);
}
Server Actions(Next.js App Router)
// app/actions.ts
'use server';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const messageSchema = z.object({
name: z.string().min(1, '名前を入力してください'),
email: z.string().email('有効なメールアドレスを入力してください'),
message: z.string().min(10, 'メッセージは10文字以上で入力してください'),
});
export async function submitMessage(prevState: any, formData: FormData) {
const submission = parseWithZod(formData, {
schema: messageSchema,
});
if (submission.status !== 'success') {
return submission.reply();
}
// データベースに保存
try {
await saveMessage(submission.value);
return submission.reply({ resetForm: true });
} catch (error) {
return submission.reply({
formErrors: ['メッセージの送信に失敗しました'],
});
}
}
// app/contact/page.tsx
'use client';
import { useFormState } from 'react-dom';
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { submitMessage } from '../actions';
export default function ContactPage() {
const [lastResult, action] = useFormState(submitMessage, undefined);
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: messageSchema });
},
shouldValidate: 'onBlur',
shouldRevalidate: 'onInput',
});
return (
<form {...form.props} action={action} className="space-y-4">
<div>
<label htmlFor={fields.name.id}>名前</label>
<input {...fields.name.props} type="text" />
<div>{fields.name.errors}</div>
</div>
<div>
<label htmlFor={fields.email.id}>メールアドレス</label>
<input {...fields.email.props} type="email" />
<div>{fields.email.errors}</div>
</div>
<div>
<label htmlFor={fields.message.id}>メッセージ</label>
<textarea {...fields.message.props} rows={5} />
<div>{fields.message.errors}</div>
</div>
<button type="submit">送信</button>
{form.errors && (
<div className="text-red-600">
{form.errors}
</div>
)}
</form>
);
}
カスタムフック
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useState } from 'react';
export function useFormWithSubmission<T>(
schema: z.ZodSchema<T>,
onSubmit: (data: T) => Promise<void>
) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
async onSubmit(event, { formData }) {
event.preventDefault();
setIsSubmitting(true);
setSubmitError(null);
const submission = parseWithZod(formData, { schema });
if (submission.status === 'success') {
try {
await onSubmit(submission.value);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'エラーが発生しました');
}
}
setIsSubmitting(false);
},
});
return {
form,
fields,
isSubmitting,
submitError,
};
}
// 使用例
function MyForm() {
const { form, fields, isSubmitting, submitError } = useFormWithSubmission(
userSchema,
async (data) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('送信に失敗しました');
}
}
);
return (
<form {...form.props}>
{/* フォームフィールド */}
{submitError && <div className="error">{submitError}</div>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中...' : '送信'}
</button>
</form>
);
}
パフォーマンス最適化
遅延バリデーション
import { useDebouncedCallback } from 'use-debounce';
function OptimizedForm() {
const [form, fields] = useForm({
onValidate: useDebouncedCallback(({ formData }) => {
return parseWithZod(formData, { schema });
}, 300), // 300ms遅延
shouldValidate: 'onInput',
});
return (
<form {...form.props}>
{/* フォームコンテンツ */}
</form>
);
}
条件付きフィールド
const conditionalSchema = z.discriminatedUnion('userType', [
z.object({
userType: z.literal('individual'),
firstName: z.string().min(1),
lastName: z.string().min(1),
}),
z.object({
userType: z.literal('business'),
companyName: z.string().min(1),
taxId: z.string().min(1),
}),
]);
function ConditionalForm() {
const [form, fields] = useForm({
onValidate({ formData }) {
return parseWithZod(formData, { schema: conditionalSchema });
},
});
const userType = form.value?.userType;
return (
<form {...form.props}>
<div>
<label>ユーザータイプ</label>
<select {...fields.userType.props}>
<option value="">選択してください</option>
<option value="individual">個人</option>
<option value="business">法人</option>
</select>
</div>
{userType === 'individual' && (
<>
<div>
<label htmlFor={fields.firstName.id}>名</label>
<input {...fields.firstName.props} type="text" />
<div>{fields.firstName.errors}</div>
</div>
<div>
<label htmlFor={fields.lastName.id}>姓</label>
<input {...fields.lastName.props} type="text" />
<div>{fields.lastName.errors}</div>
</div>
</>
)}
{userType === 'business' && (
<>
<div>
<label htmlFor={fields.companyName.id}>会社名</label>
<input {...fields.companyName.props} type="text" />
<div>{fields.companyName.errors}</div>
</div>
<div>
<label htmlFor={fields.taxId.id}>法人番号</label>
<input {...fields.taxId.props} type="text" />
<div>{fields.taxId.errors}</div>
</div>
</>
)}
<button type="submit">送信</button>
</form>
);
}
まとめ
Conformは Progressive Enhancement の原則に従いながら、現代的なReactアプリケーションのフォーム要件を満たす優れたライブラリです。
主な利点:
- 🌐 Progressive Enhancement: JavaScriptが無効でも基本機能が動作
- 🛡️ 型安全性: TypeScriptとZodの完全な統合
- ♿ アクセシビリティ: ARIA属性とフォーカス管理の自動化
- 🚀 パフォーマンス: 効率的な再レンダリングと遅延バリデーション
- 🔧 柔軟性: Server Actions、ファイルアップロード、動的フィールドに対応
Conformを使用することで、ユーザーフレンドリーで保守性の高いフォームを効率的に実装できます。