AI時代のReact入門 第13回:フォームの実装基礎

「バリデーションってどうやって実装するの?」 「フォームの状態管理がごちゃごちゃしてきた…」

Reactでフォームを実装しようとすると、意外と悩むポイントが多いものです。入力値の管理、バリデーション、エラーメッセージの表示など、考慮すべき要素が次々と出てきますよね。

V0とCursorを活用することで、これらの実装を効率的に進めることができます。実際の開発の流れを見ていきましょう。

V0での開発スタート

まず、V0に以下のように依頼して基本的なログインフォームを生成しました:

ログインフォームを作成してください:
- メールアドレスとパスワードの入力欄
- バリデーション機能
- TailwindCSSでスタイリング
- エラー表示機能

V0は以下のようなコードを生成してくれました:

jsx
function LoginForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });
  const [errors, setErrors] = useState({});

  const handleSubmit = (e) => {
    e.preventDefault();
    // 基本的なバリデーション
    const newErrors = {};
    if (!formData.email) newErrors.email = '必須項目です';
    if (!formData.password) newErrors.password = '必須項目です';
    setErrors(newErrors);
  };

  // ... 残りの実装
}

Cursorでの改善

生成されたコードをベースに、Cursorを使って改善していきましょう。

jsx
import React, { useState, useEffect } from 'react';

const LoginForm = () => {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });
  
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [touchedFields, setTouchedFields] = useState({
    email: false,
    password: false
  });

  // パスワードの強度チェック
  const validatePassword = (password) => {
    const errors = [];
    if (password.length < 8) {
      errors.push('8文字以上である必要があります');
    }
    if (!/[A-Z]/.test(password)) {
      errors.push('大文字を含める必要があります');
    }
    if (!/[a-z]/.test(password)) {
      errors.push('小文字を含める必要があります');
    }
    if (!/[0-9]/.test(password)) {
      errors.push('数字を含める必要があります');
    }
    return errors;
  };

  // フォームの検証
  const validateField = (name, value) => {
    if (!value) {
      return `${name === 'email' ? 'メールアドレス' : 'パスワード'}は必須です`;
    }
    if (name === 'email' && !/\S+@\S+\.\S+/.test(value)) {
      return '有効なメールアドレスを入力してください';
    }
    if (name === 'password') {
      const passwordErrors = validatePassword(value);
      if (passwordErrors.length > 0) {
        return passwordErrors.join(', ');
      }
    }
    return '';
  };

  // 入力値の変更処理
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));

    // タッチ済みのフィールドのみバリデーション
    if (touchedFields[name]) {
      const error = validateField(name, value);
      setErrors(prev => ({
        ...prev,
        [name]: error
      }));
    }
  };

  // フィールドからフォーカスが外れた時の処理
  const handleBlur = (e) => {
    const { name } = e.target;
    setTouchedFields(prev => ({
      ...prev,
      [name]: true
    }));
    
    const error = validateField(name, formData[name]);
    setErrors(prev => ({
      ...prev,
      [name]: error
    }));
  };

  // フォーム送信処理
  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);

    // 全フィールドの検証
    const newErrors = {};
    Object.keys(formData).forEach(key => {
      const error = validateField(key, formData[key]);
      if (error) newErrors[key] = error;
    });

    if (Object.keys(newErrors).length === 0) {
      try {
        // ここで実際のログイン処理を実装
        await new Promise(resolve => setTimeout(resolve, 1000));
        alert('ログイン成功!');
        // ログイン成功後の処理
      } catch (error) {
        setErrors({ submit: 'ログインに失敗しました。' });
      }
    } else {
      setErrors(newErrors);
    }
    
    setIsSubmitting(false);
  };

  return (
    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
      <h2 className="text-2xl font-bold mb-6 text-gray-800">ログイン</h2>
      
      <form onSubmit={handleSubmit} className="space-y-6" noValidate>
        <div>
          <label 
            htmlFor="email"
            className="block text-sm font-medium text-gray-700 mb-1"
          >
            メールアドレス
          </label>
          <input
            id="email"
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            onBlur={handleBlur}
            className={`block w-full px-3 py-2 border rounded-md shadow-sm
              focus:outline-none focus:ring-2
              ${errors.email 
                ? 'border-red-300 focus:ring-red-500' 
                : 'border-gray-300 focus:ring-blue-500'}`}
            aria-invalid={errors.email ? 'true' : 'false'}
            aria-describedby={errors.email ? 'email-error' : undefined}
            disabled={isSubmitting}
          />
          {errors.email && (
            <p 
              id="email-error" 
              className="mt-1 text-sm text-red-600"
              role="alert"
            >
              {errors.email}
            </p>
          )}
        </div>

        <div>
          <label 
            htmlFor="password"
            className="block text-sm font-medium text-gray-700 mb-1"
          >
            パスワード
          </label>
          <input
            id="password"
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            onBlur={handleBlur}
            className={`block w-full px-3 py-2 border rounded-md shadow-sm
              focus:outline-none focus:ring-2
              ${errors.password 
                ? 'border-red-300 focus:ring-red-500' 
                : 'border-gray-300 focus:ring-blue-500'}`}
            aria-invalid={errors.password ? 'true' : 'false'}
            aria-describedby={errors.password ? 'password-error' : undefined}
            disabled={isSubmitting}
          />
          {errors.password && (
            <p 
              id="password-error" 
              className="mt-1 text-sm text-red-600"
              role="alert"
            >
              {errors.password}
            </p>
          )}
        </div>

        {errors.submit && (
          <div 
            className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded"
            role="alert"
          >
            {errors.submit}
          </div>
        )}

        <button
          type="submit"
          disabled={isSubmitting}
          className="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
        >
          {isSubmitting ? (
            <span className="flex items-center justify-center">
              <svg 
                className="animate-spin h-5 w-5 mr-2" 
                fill="none" 
                viewBox="0 0 24 24"
              >
                <circle 
                  className="opacity-25" 
                  cx="12" 
                  cy="12" 
                  r="10" 
                  stroke="currentColor" 
                  strokeWidth="4"
                />
                <path 
                  className="opacity-75" 
                  fill="currentColor" 
                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
                />
              </svg>
              ログイン中...
            </span>
          ) : (
            'ログイン'
          )}
        </button>
      </form>
    </div>
  );
};

export default LoginForm;

1. バリデーションの強化

Cursorに以下のように質問します:

このログインフォームのバリデーションを改善したいです:
- メールアドレスの形式チェック
- パスワードの強度チェック(8文字以上、大文字小文字数字を含む)
- エラーメッセージの日本語化
実装方法を提案してください。

Cursorからの提案を取り入れて、以下のように改善しました:

2. ユーザー体験の改善

次に、Cursorに以下のように依頼して、UXを改善します:

フォームのUXを改善したいです:
- 送信中のローディング表示
- 入力中のリアルタイムバリデーション
- フォーカス時のスタイリング

まとめ

今回は、V0とCursorを活用したフォーム実装の基礎を学びました:

  • V0でベースとなるコードを生成
  • Cursorで機能の改善と最適化
  • AIとの対話で段階的な機能追加

次回予告

次回は「シンプルなTodoアプリを作る」について学んでいきます。

フォームの知識を活かしながら、ToDoアプリの作成を通じてCRUD操作の基本を学んでいきましょう。