DT 実装例・チュートリアル ConformでモダンなReactフォームバリデーション入門

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を使用することで、ユーザーフレンドリーで保守性の高いフォームを効率的に実装できます。