第 11 章:表單處理 (Forms)

表單是 Web 應用程式中收集使用者輸入的主要方式。在 React 中,處理表單有其特殊的模式和最佳實踐。React 的表單處理主要分為兩種方式:受控元件 (Controlled Components) 和不受控元件 (Uncontrolled Components)。在這一章中,我們將深入學習如何在 React 中有效地處理表單。

11.1 受控元件 vs 不受控元件

11.1.1 受控元件 (Controlled Components)

在受控元件中,表單元素的值由 React 的 state 控制。這意味著表單元素的當前值總是由 state 決定,並且所有的輸入變化都會觸發 state 更新。

import React, { useState } from 'react';

function ControlledForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    message: ''
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('提交的資料:', formData);
    alert(`使用者名稱: ${formData.username}\n信箱: ${formData.email}\n訊息: ${formData.message}`);
  };

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h3>受控元件範例</h3>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="username">使用者名稱:</label>
          <input
            type="text"
            id="username"
            name="username"
            value={formData.username}
            onChange={handleChange}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="email">電子信箱:</label>
          <input
            type="email"
            id="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="message">訊息:</label>
          <textarea
            id="message"
            name="message"
            value={formData.message}
            onChange={handleChange}
            rows="4"
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px',
              resize: 'vertical'
            }}
          />
        </div>

        <button
          type="submit"
          style={{
            width: '100%',
            padding: '10px',
            backgroundColor: '#4CAF50',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            fontSize: '16px'
          }}
        >
          提交
        </button>
      </form>

      <div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
        <h4>即時預覽:</h4>
        <p><strong>使用者名稱:</strong> {formData.username || '(未填寫)'}</p>
        <p><strong>信箱:</strong> {formData.email || '(未填寫)'}</p>
        <p><strong>訊息:</strong> {formData.message || '(未填寫)'}</p>
      </div>
    </div>
  );
}

export default ControlledForm;

11.1.2 不受控元件 (Uncontrolled Components)

在不受控元件中,表單元素自己維護其內部 state,React 透過 ref 來取得表單的值。

import React, { useRef } from 'react';

function UncontrolledForm() {
  const usernameRef = useRef();
  const emailRef = useRef();
  const messageRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();

    const formData = {
      username: usernameRef.current.value,
      email: emailRef.current.value,
      message: messageRef.current.value
    };

    console.log('提交的資料:', formData);
    alert(`使用者名稱: ${formData.username}\n信箱: ${formData.email}\n訊息: ${formData.message}`);

    // 清空表單
    usernameRef.current.value = '';
    emailRef.current.value = '';
    messageRef.current.value = '';
  };

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h3>不受控元件範例</h3>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="username">使用者名稱:</label>
          <input
            type="text"
            id="username"
            ref={usernameRef}
            defaultValue="" // 使用 defaultValue 而不是 value
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="email">電子信箱:</label>
          <input
            type="email"
            id="email"
            ref={emailRef}
            defaultValue=""
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="message">訊息:</label>
          <textarea
            id="message"
            ref={messageRef}
            defaultValue=""
            rows="4"
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px',
              resize: 'vertical'
            }}
          />
        </div>

        <button
          type="submit"
          style={{
            width: '100%',
            padding: '10px',
            backgroundColor: '#2196F3',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            fontSize: '16px'
          }}
        >
          提交
        </button>
      </form>
    </div>
  );
}

export default UncontrolledForm;

11.2 處理不同類型的表單元素

11.2.1 文字輸入和文字區域

import React, { useState } from 'react';

function TextInputs() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    biography: '',
    password: '',
    confirmPassword: ''
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  const passwordsMatch = formData.password === formData.confirmPassword;
  const passwordValid = formData.password.length >= 6;

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
      <h3>文字輸入範例</h3>
      <form>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginBottom: '15px' }}>
          <div>
            <label htmlFor="firstName">名字:</label>
            <input
              type="text"
              id="firstName"
              name="firstName"
              value={formData.firstName}
              onChange={handleChange}
              placeholder="請輸入名字"
              style={{
                width: '100%',
                padding: '8px',
                marginTop: '5px',
                border: '1px solid #ddd',
                borderRadius: '4px'
              }}
            />
          </div>

          <div>
            <label htmlFor="lastName">姓氏:</label>
            <input
              type="text"
              id="lastName"
              name="lastName"
              value={formData.lastName}
              onChange={handleChange}
              placeholder="請輸入姓氏"
              style={{
                width: '100%',
                padding: '8px',
                marginTop: '5px',
                border: '1px solid #ddd',
                borderRadius: '4px'
              }}
            />
          </div>
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="password">密碼:</label>
          <input
            type="password"
            id="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            placeholder="至少 6 個字元"
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: `1px solid ${passwordValid || !formData.password ? '#ddd' : '#f44336'}`,
              borderRadius: '4px'
            }}
          />
          {formData.password && !passwordValid && (
            <small style={{ color: '#f44336' }}>密碼至少需要 6 個字元</small>
          )}
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="confirmPassword">確認密碼:</label>
          <input
            type="password"
            id="confirmPassword"
            name="confirmPassword"
            value={formData.confirmPassword}
            onChange={handleChange}
            placeholder="請再次輸入密碼"
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: `1px solid ${passwordsMatch || !formData.confirmPassword ? '#ddd' : '#f44336'}`,
              borderRadius: '4px'
            }}
          />
          {formData.confirmPassword && !passwordsMatch && (
            <small style={{ color: '#f44336' }}>密碼不一致</small>
          )}
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="biography">個人簡介:</label>
          <textarea
            id="biography"
            name="biography"
            value={formData.biography}
            onChange={handleChange}
            placeholder="請簡單介紹自己..."
            rows="4"
            maxLength="500"
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px',
              resize: 'vertical'
            }}
          />
          <small style={{ color: '#666' }}>
            {formData.biography.length}/500 字元
          </small>
        </div>

        <div style={{ padding: '15px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
          <h4>表單資料預覽:</h4>
          <p><strong>全名:</strong> {formData.firstName} {formData.lastName}</p>
          <p><strong>密碼狀態:</strong> 
            {!formData.password ? '未設定' : 
             !passwordValid ? '太短' :
             !passwordsMatch ? '不一致' : '✓ 有效'}
          </p>
          <p><strong>簡介長度:</strong> {formData.biography.length} 字元</p>
        </div>
      </form>
    </div>
  );
}

export default TextInputs;

11.2.2 選擇元素 (Select, Radio, Checkbox)

import React, { useState } from 'react';

function SelectionInputs() {
  const [formData, setFormData] = useState({
    country: '',
    gender: '',
    hobbies: [],
    newsletter: false,
    terms: false,
    experience: ''
  });

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;

    if (type === 'checkbox') {
      if (name === 'hobbies') {
        // 處理多選 checkbox
        setFormData(prev => ({
          ...prev,
          hobbies: checked 
            ? [...prev.hobbies, value]
            : prev.hobbies.filter(hobby => hobby !== value)
        }));
      } else {
        // 處理單一 checkbox
        setFormData(prev => ({
          ...prev,
          [name]: checked
        }));
      }
    } else {
      setFormData(prev => ({
        ...prev,
        [name]: value
      }));
    }
  };

  const countries = [
    { value: '', label: '請選擇國家' },
    { value: 'tw', label: '台灣' },
    { value: 'cn', label: '中國' },
    { value: 'jp', label: '日本' },
    { value: 'kr', label: '韓國' },
    { value: 'us', label: '美國' },
    { value: 'other', label: '其他' }
  ];

  const hobbyOptions = [
    { value: 'reading', label: '閱讀' },
    { value: 'sports', label: '運動' },
    { value: 'music', label: '音樂' },
    { value: 'travel', label: '旅行' },
    { value: 'cooking', label: '烹飪' },
    { value: 'gaming', label: '遊戲' }
  ];

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
      <h3>選擇元素範例</h3>
      <form>
        {/* 下拉選單 */}
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="country">國家:</label>
          <select
            id="country"
            name="country"
            value={formData.country}
            onChange={handleChange}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          >
            {countries.map(country => (
              <option key={country.value} value={country.value}>
                {country.label}
              </option>
            ))}
          </select>
        </div>

        {/* 單選按鈕 */}
        <div style={{ marginBottom: '20px' }}>
          <label>性別:</label>
          <div style={{ marginTop: '5px' }}>
            <label style={{ display: 'block', marginBottom: '5px' }}>
              <input
                type="radio"
                name="gender"
                value="male"
                checked={formData.gender === 'male'}
                onChange={handleChange}
                style={{ marginRight: '8px }}
              />
              男性
            </label>
            <label style={{ display: 'block', marginBottom: '5px' }}>
              <input
                type="radio"
                name="gender"
                value="female"
                checked={formData.gender === 'female'}
                onChange={handleChange}
                style={{ marginRight: '8px }}
              />
              女性
            </label>
            <label style={{ display: 'block' }}>
              <input
                type="radio"
                name="gender"
                value="other"
                checked={formData.gender === 'other'}
                onChange={handleChange}
                style={{ marginRight: '8px }}
              />
              其他
            </label>
          </div>
        </div>

        {/* 經驗等級 (Range) */}
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="experience">程式設計經驗 ({formData.experience || 0} 年):</label>
          <input
            type="range"
            id="experience"
            name="experience"
            min="0"
            max="20"
            value={formData.experience}
            onChange={handleChange}
            style={{
              width: '100%',
              marginTop: '5px"
            }}
          />
          <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8em', color: '#666' }}>
            <span>0年</span>
            <span>10年</span>
            <span>20年+</span>
          </div>
        </div>

        {/* 多選 Checkbox */}
        <div style={{ marginBottom: '20px' }}>
          <label>興趣愛好 (可多選):</label>
          <div style={{ marginTop: '5px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}>
            {hobbyOptions.map(hobby => (
              <label key={hobby.value} style={{ display: 'flex', alignItems: 'center' }}>
                <input
                  type="checkbox"
                  name="hobbies"
                  value={hobby.value}
                  checked={formData.hobbies.includes(hobby.value)}
                  onChange={handleChange}
                  style={{ marginRight: '8px }}
                />
                {hobby.label}
              </label>
            ))}
          </div>
        </div>

        {/* 單一 Checkbox */}
        <div style={{ marginBottom: '20px' }}>
          <label style={{ display: 'flex', alignItems: 'center', marginBottom: '10px' }}>
            <input
              type="checkbox"
              name="newsletter"
              checked={formData.newsletter}
              onChange={handleChange}
              style={{ marginRight: '8px }}
            />
            訂閱電子報
          </label>

          <label style={{ display: 'flex', alignItems: 'center' }}>
            <input
              type="checkbox"
              name="terms"
              checked={formData.terms}
              onChange={handleChange}
              style={{ marginRight: '8px }}
            />
            我同意<a href="#" style={{ color: '#2196F3' }}>服務條款</a>
          </label>
        </div>

        <button
          type="submit"
          disabled={!formData.terms}
          style={{
            width: '100%',
            padding: '10px',
            backgroundColor: formData.terms ? '#4CAF50' : '#ccc',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: formData.terms ? 'pointer' : 'not-allowed',
            fontSize: '16px'
          }}
        >
          提交表單
        </button>

        <div style={{ marginTop: '20px', padding: '15px', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
          <h4>選擇結果:</h4>
          <p><strong>國家:</strong> {formData.country || '未選擇'}</p>
          <p><strong>性別:</strong> {formData.gender || '未選擇'}</p>
          <p><strong>經驗:</strong> {formData.experience || 0} 年</p>
          <p><strong>興趣:</strong> {formData.hobbies.length > 0 ? formData.hobbies.join(', ') : '未選擇'}</p>
          <p><strong>電子報:</strong> {formData.newsletter ? '已訂閱' : '未訂閱'}</p>
          <p><strong>條款:</strong> {formData.terms ? '已同意' : '未同意'}</p>
        </div>
      </form>
    </div>
  );
}

export default SelectionInputs;

11.3 表單驗證

import React, { useState } from 'react';

function FormValidation() {
  const [formData, setFormData] = useState({
    email: '',
    username: '',
    password: '',
    confirmPassword: '',
    age: '',
    website: ''
  });

  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  // 驗證規則
  const validateField = (name, value) => {
    switch (name) {
      case 'email':
        if (!value) return '電子信箱為必填欄位';
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '請輸入有效的電子信箱';
        return '';

      case 'username':
        if (!value) return '使用者名稱為必填欄位';
        if (value.length < 3) return '使用者名稱至少需要 3 個字元';
        if (!/^[a-zA-Z0-9_]+$/.test(value)) return '只能包含字母、數字和下底線';
        return '';

      case 'password':
        if (!value) return '密碼為必填欄位';
        if (value.length < 8) return '密碼至少需要 8 個字元';
        if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
          return '密碼必須包含大小寫字母和數字';
        }
        return '';

      case 'confirmPassword':
        if (!value) return '請確認密碼';
        if (value !== formData.password) return '密碼不一致';
        return '';

      case 'age':
        if (!value) return '年齡為必填欄位';
        if (isNaN(value) || value < 1 || value > 120) return '請輸入有效的年齡 (1-120)';
        return '';

      case 'website':
        if (value && !/^https?:\/\/.+/.test(value)) return '請輸入有效的網址 (需包含 http:// 或 https://)';
        return '';

      default:
        return '';
    }
  };

  const handleChange = (e) => {
    const { name, value } = e.target;

    setFormData(prev => ({
      ...prev,
      [name]: value
    }));

    // 即時驗證
    if (touched[name]) {
      const error = validateField(name, value);
      setErrors(prev => ({
        ...prev,
        [name]: error
      }));
    }
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;

    setTouched(prev => ({
      ...prev,
      [name]: true
    }));

    const error = validateField(name, value);
    setErrors(prev => ({
      ...prev,
      [name]: error
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    // 驗證所有欄位
    const newErrors = {};
    const newTouched = {};

    Object.keys(formData).forEach(key => {
      newTouched[key] = true;
      const error = validateField(key, formData[key]);
      if (error) newErrors[key] = error;
    });

    setTouched(newTouched);
    setErrors(newErrors);

    // 如果沒有錯誤,提交表單
    if (Object.keys(newErrors).length === 0) {
      alert('表單驗證通過!資料已提交。');
      console.log('提交的資料:', formData);
    }
  };

  const getFieldStyle = (fieldName) => ({
    width: '100%',
    padding: '8px',
    marginTop: '5px',
    border: `1px solid ${errors[fieldName] && touched[fieldName] ? '#f44336' : '#ddd'}`,
    borderRadius: '4px',
    backgroundColor: errors[fieldName] && touched[fieldName] ? '#ffebee' : 'white'
  });

  const isFormValid = Object.keys(formData).every(key => 
    !validateField(key, formData[key]) && formData[key] !== ''
  );

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
      <h3>表單驗證範例</h3>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="email">電子信箱 *:</label>
          <input
            type="email"
            id="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('email')}
            placeholder="example@email.com"
          />
          {errors.email && touched.email && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.email}
            </small>
          )}
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="username">使用者名稱 *:</label>
          <input
            type="text"
            id="username"
            name="username"
            value={formData.username}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('username')}
            placeholder="至少 3 個字元,只能包含字母、數字和下底線"
          />
          {errors.username && touched.username && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.username}
            </small>
          )}
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="password">密碼 *:</label>
          <input
            type="password"
            id="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('password')}
            placeholder="至少 8 個字元,包含大小寫字母和數字"
          />
          {errors.password && touched.password && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.password}
            </small>
          )}
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="confirmPassword">確認密碼 *:</label>
          <input
            type="password"
            id="confirmPassword"
            name="confirmPassword"
            value={formData.confirmPassword}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('confirmPassword')}
            placeholder="請再次輸入密碼"
          />
          {errors.confirmPassword && touched.confirmPassword && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.confirmPassword}
            </small>
          )}
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="age">年齡 *:</label>
          <input
            type="number"
            id="age"
            name="age"
            value={formData.age}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('age')}
            placeholder="請輸入您的年齡"
            min="1"
            max="120"
          />
          {errors.age && touched.age && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.age}
            </small>
          )}
        </div>

        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="website">個人網站 (選填):</label>
          <input
            type="url"
            id="website"
            name="website"
            value={formData.website}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('website')}
            placeholder="https://example.com"
          />
          {errors.website && touched.website && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.website}
            </small>
          )}
        </div>

        <button
          type="submit"
          style={{
            width: '100%',
            padding: '12px',
            backgroundColor: isFormValid ? '#4CAF50' : '#ccc',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: isFormValid ? 'pointer' : 'not-allowed',
            fontSize: '16px',
            marginBottom: '20px'
          }}
        >
          提交註冊
        </button>

        <div style={{ padding: '15px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
          <h4>表單狀態:</h4>
          <p><strong>表單有效:</strong> {isFormValid ? '✓ 是' : '✗ 否'}</p>
          <p><strong>錯誤數量:</strong> {Object.keys(errors).filter(key => errors[key]).length}</p>
          <p><strong>已驗證欄位:</strong> {Object.keys(touched).filter(key => touched[key]).length}/{Object.keys(formData).length}</p>
        </div>
      </form>
    </div>
  );
}

export default FormValidation;

11.4 複雜表單處理

11.4.1 多步驟表單

import React, { useState } from 'react';

function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState(1);
  const [formData, setFormData] = useState({
    // 步驟 1: 個人資訊
    personalInfo: {
      firstName: '',
      lastName: '',
      email: '',
      phone: ''
    },
    // 步驟 2: 地址資訊
    addressInfo: {
      street: '',
      city: '',
      state: '',
      zipCode: '',
      country: 'tw'
    },
    // 步驟 3: 偏好設定
    preferences: {
      newsletter: false,
      notifications: 'email',
      language: 'zh-TW',
      interests: []
    }
  });

  const totalSteps = 3;

  const updateFormData = (section, field, value) => {
    setFormData(prev => ({
      ...prev,
      [section]: {
        ...prev[section],
        [field]: value
      }
    }));
  };

  const handleNext = () => {
    if (currentStep < totalSteps) {
      setCurrentStep(currentStep + 1);
    }
  };

  const handlePrev = () => {
    if (currentStep > 1) {
      setCurrentStep(currentStep - 1);
    }
  };

  const handleSubmit = () => {
    console.log('完整表單資料:', formData);
    alert('表單提交成功!');
  };

  const isStepValid = (step) => {
    switch (step) {
      case 1:
        return formData.personalInfo.firstName && 
               formData.personalInfo.lastName && 
               formData.personalInfo.email;
      case 2:
        return formData.addressInfo.street && 
               formData.addressInfo.city && 
               formData.addressInfo.zipCode;
      case 3:
        return true; // 偏好設定都是選填
      default:
        return false;
    }
  };

  const renderStepIndicator = () => (
    <div style={{ display: 'flex', marginBottom: '30px' }}>
      {[1, 2, 3].map(step => (
        <div key={step} style={{ flex: 1, textAlign: 'center' }}>
          <div style={{
            width: '30px',
            height: '30px',
            borderRadius: '50%',
            backgroundColor: step <= currentStep ? '#4CAF50' : '#ddd',
            color: step <= currentStep ? 'white' : '#666',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            margin: '0 auto 10px',
            fontSize: '14px',
            fontWeight: 'bold'
          }}>
            {step}
          </div>
          <div style={{ fontSize: '12px', color: '#666' }}>
            {step === 1 && '個人資訊'}
            {step === 2 && '地址資訊'}
            {step === 3 && '偏好設定'}
          </div>
        </div>
      ))}
    </div>
  );

  const renderStep1 = () => (
    <div>
      <h3>步驟 1: 個人資訊</h3>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px', marginBottom: '15px' }}>
        <div>
          <label>名字 *:</label>
          <input
            type="text"
            value={formData.personalInfo.firstName}
            onChange={(e) => updateFormData('personalInfo', 'firstName', e.target.value)}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>
        <div>
          <label>姓氏 *:</label>
          <input
            type="text"
            value={formData.personalInfo.lastName}
            onChange={(e) => updateFormData('personalInfo', 'lastName', e.target.value)}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>
      </div>
      <div style={{ marginBottom: '15px' }}>
        <label>電子信箱 *:</label>
        <input
          type="email"
          value={formData.personalInfo.email}
          onChange={(e) => updateFormData('personalInfo', 'email', e.target.value)}
          style={{
            width: '100%',
            padding: '8px',
            marginTop: '5px',
            border: '1px solid #ddd',
            borderRadius: '4px'
          }}
        />
      </div>
      <div style={{ marginBottom: '15px' }}>
        <label>電話號碼:</label>
        <input
          type="tel"
          value={formData.personalInfo.phone}
          onChange={(e) => updateFormData('personalInfo', 'phone', e.target.value)}
          style={{
            width: '100%',
            padding: '8px',
            marginTop: '5px',
            border: '1px solid #ddd',
            borderRadius: '4px'
          }}
        />
      </div>
    </div>
  );

  const renderStep2 = () => (
    <div>
      <h3>步驟 2: 地址資訊</h3>
      <div style={{ marginBottom: '15px' }}>
        <label>街道地址 *:</label>
        <input
          type="text"
          value={formData.addressInfo.street}
          onChange={(e) => updateFormData('addressInfo', 'street', e.target.value)}
          style={{
            width: '100%',
            padding: '8px',
            marginTop: '5px',
            border: '1px solid #ddd',
            borderRadius: '4px'
          }}
        />
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px', marginBottom: '15px' }}>
        <div>
          <label>城市 *:</label>
          <input
            type="text"
            value={formData.addressInfo.city}
            onChange={(e) => updateFormData('addressInfo', 'city', e.target.value)}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>
        <div>
          <label>州/省:</label>
          <input
            type="text"
            value={formData.addressInfo.state}
            onChange={(e) => updateFormData('addressInfo', 'state', e.target.value)}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
        <div>
          <label>郵遞區號 *:</label>
          <input
            type="text"
            value={formData.addressInfo.zipCode}
            onChange={(e) => updateFormData('addressInfo', 'zipCode', e.target.value)}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          />
        </div>
        <div>
          <label>國家:</label>
          <select
            value={formData.addressInfo.country}
            onChange={(e) => updateFormData('addressInfo', 'country', e.target.value)}
            style={{
              width: '100%',
              padding: '8px',
              marginTop: '5px',
              border: '1px solid #ddd',
              borderRadius: '4px'
            }}
          >
            <option value="tw">台灣</option>
            <option value="cn">中國</option>
            <option value="jp">日本</option>
            <option value="us">美國</option>
          </select>
        </div>
      </div>
    </div>
  );

  const renderStep3 = () => (
    <div>
      <h3>步驟 3: 偏好設定</h3>
      <div style={{ marginBottom: '20px' }}>
        <label>通知方式:</label>
        <div style={{ marginTop: '10px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>
            <input
              type="radio"
              name="notifications"
              value="email"
              checked={formData.preferences.notifications === 'email'}
              onChange={(e) => updateFormData('preferences', 'notifications', e.target.value)}
              style={{ marginRight: '8px }}
            />
            電子郵件
          </label>
          <label style={{ display: 'block', marginBottom: '5px' }}>
            <input
              type="radio"
              name="notifications"
              value="sms"
              checked={formData.preferences.notifications === 'sms'}
              onChange={(e) => updateFormData('preferences', 'notifications', e.target.value)}
              style={{ marginRight: '8px }}
            />
            簡訊
          </label>
          <label style={{ display: 'block' }}>
            <input
              type="radio"
              name="notifications"
              value="none"
              checked={formData.preferences.notifications === 'none'}
              onChange={(e) => updateFormData('preferences', 'notifications', e.target.value)}
              style={{ marginRight: '8px }}
            />
            不接收通知
          </label>
        </div>
      </div>

      <div style={{ marginBottom: '20px' }}>
        <label>語言偏好:</label>
        <select
          value={formData.preferences.language}
          onChange={(e) => updateFormData('preferences', 'language', e.target.value)}
          style={{
            width: '100%',
            padding: '8px',
            marginTop: '5px',
            border: '1px solid #ddd',
            borderRadius: '4px'
          }}
        >
          <option value="zh-TW">繁體中文</option>
          <option value="zh-CN">簡體中文</option>
          <option value="en">English</option>
          <option value="ja">日本語</option>
        </select>
      </div>

      <div style={{ marginBottom: '20px' }}>
        <label style={{ display: 'flex', alignItems: 'center' }}>
          <input
            type="checkbox"
            checked={formData.preferences.newsletter}
            onChange={(e) => updateFormData('preferences', 'newsletter', e.target.checked)}
            style={{ marginRight: '8px }}
          />
          訂閱電子報
        </label>
      </div>
    </div>
  );

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
      <h2>多步驟註冊表單</h2>

      {renderStepIndicator()}

      <div style={{ 
        border: '1px solid #ddd', 
        borderRadius: '8px', 
        padding: '30px',
        minHeight: '400px'
      }}>
        {currentStep === 1 && renderStep1()}
        {currentStep === 2 && renderStep2()}
        {currentStep === 3 && renderStep3()}

        <div style={{ 
          display: 'flex', 
          justifyContent: 'space-between', 
          marginTop: '30px',
          paddingTop: '20px',
          borderTop: '1px solid #eee'
        }}>
          <button
            onClick={handlePrev}
            disabled={currentStep === 1}
            style={{
              padding: '10px 20px',
              backgroundColor: currentStep === 1 ? '#ccc' : '#2196F3',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: currentStep === 1 ? 'not-allowed' : 'pointer'
            }}
          >
            上一步
          </button>

          {currentStep < totalSteps ? (
            <button
              onClick={handleNext}
              disabled={!isStepValid(currentStep)}
              style={{
                padding: '10px 20px',
                backgroundColor: isStepValid(currentStep) ? '#4CAF50' : '#ccc',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: isStepValid(currentStep) ? 'pointer' : 'not-allowed'
              }}
            >
              下一步
            </button>
          ) : (
            <button
              onClick={handleSubmit}
              style={{
                padding: '10px 20px',
                backgroundColor: '#FF5722',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
              }}
            >
              提交註冊
            </button>
          )}
        </div>
      </div>

      <div style={{ marginTop: '20px', fontSize: '0.9em', color: '#666' }}>
        進度: {currentStep}/{totalSteps} 
        ({Math.round((currentStep / totalSteps) * 100)}%)
      </div>
    </div>
  );
}

export default MultiStepForm;

11.4.2 表單處理的自訂 Hook

// hooks/useForm.js
import { useState, useCallback } from 'react';

function useForm(initialValues, validationRules = {}) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validate = useCallback((name, value) => {
    if (validationRules[name]) {
      return validationRules[name](value, values);
    }
    return '';
  }, [validationRules, values]);

  const setValue = useCallback((name, value) => {
    setValues(prev => ({
      ...prev,
      [name]: value
    }));

    // 如果欄位已經被觸摸過,立即驗證
    if (touched[name]) {
      const error = validate(name, value);
      setErrors(prev => ({
        ...prev,
        [name]: error
      }));
    }
  }, [touched, validate]);

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const fieldValue = type === 'checkbox' ? checked : value;
    setValue(name, fieldValue);
  }, [setValue]);

  const handleBlur = useCallback((e) => {
    const { name, value } = e.target;

    setTouched(prev => ({
      ...prev,
      [name]: true
    }));

    const error = validate(name, value);
    setErrors(prev => ({
      ...prev,
      [name]: error
    }));
  }, [validate]);

  const validateAll = useCallback(() => {
    const newErrors = {};
    const newTouched = {};

    Object.keys(values).forEach(key => {
      newTouched[key] = true;
      const error = validate(key, values[key]);
      if (error) newErrors[key] = error;
    });

    setTouched(newTouched);
    setErrors(newErrors);

    return Object.keys(newErrors).length === 0;
  }, [values, validate]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  const isValid = Object.keys(errors).every(key => !errors[key]);

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    setValue,
    validateAll,
    reset,
    isValid
  };
}

export default useForm;

使用自訂 Hook 的範例:

// components/LoginForm.js
import React from 'react';
import useForm from '../hooks/useForm';

const validationRules = {
  email: (value) => {
    if (!value) return '電子信箱為必填欄位';
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '請輸入有效的電子信箱';
    return '';
  },
  password: (value) => {
    if (!value) return '密碼為必填欄位';
    if (value.length < 6) return '密碼至少需要 6 個字元';
    return '';
  }
};

function LoginForm() {
  const {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validateAll,
    reset,
    isValid
  } = useForm(
    { email: '', password: '', rememberMe: false },
    validationRules
  );

  const handleSubmit = (e) => {
    e.preventDefault();

    if (validateAll()) {
      console.log('登入資料:', values);
      alert('登入成功!');
      reset();
    }
  };

  const getFieldStyle = (fieldName) => ({
    width: '100%',
    padding: '10px',
    marginTop: '5px',
    border: `1px solid ${errors[fieldName] && touched[fieldName] ? '#f44336' : '#ddd'}`,
    borderRadius: '4px',
    fontSize: '16px'
  });

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
      <h2>登入</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '20px' }}>
          <label>電子信箱:</label>
          <input
            type="email"
            name="email"
            value={values.email}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('email')}
            placeholder="請輸入您的電子信箱"
          />
          {errors.email && touched.email && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.email}
            </small>
          )}
        </div>

        <div style={{ marginBottom: '20px' }}>
          <label>密碼:</label>
          <input
            type="password"
            name="password"
            value={values.password}
            onChange={handleChange}
            onBlur={handleBlur}
            style={getFieldStyle('password')}
            placeholder="請輸入您的密碼"
          />
          {errors.password && touched.password && (
            <small style={{ color: '#f44336', display: 'block', marginTop: '5px' }}>
              {errors.password}
            </small>
          )}
        </div>

        <div style={{ marginBottom: '20px' }}>
          <label style={{ display: 'flex', alignItems: 'center' }}>
            <input
              type="checkbox"
              name="rememberMe"
              checked={values.rememberMe}
              onChange={handleChange}
              style={{ marginRight: '8px' }}
            />
            記住我
          </label>
        </div>

        <button
          type="submit"
          style={{
            width: '100%',
            padding: '12px',
            backgroundColor: '#2196F3',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            fontSize: '16px',
            cursor: 'pointer',
            marginBottom: '10px'
          }}
        >
          登入
        </button>

        <button
          type="button"
          onClick={reset}
          style={{
            width: '100%',
            padding: '8px',
            backgroundColor: 'transparent',
            color: '#666',
            border: '1px solid #ddd',
            borderRadius: '4px',
            cursor: 'pointer'
          }}
        >
          清除表單
        </button>
      </form>

      <div style={{ marginTop: '20px', fontSize: '0.9em', color: '#666' }}>
        表單狀態: {isValid ? '✓ 有效' : '✗ 有錯誤'}
      </div>
    </div>
  );
}

export default LoginForm;

11.5 表單處理的最佳實踐

11.5.1 效能優化

  1. 使用 useCallback 穩定事件處理函式
  2. 避免在每次渲染時創建新的物件
  3. 考慮使用 React.memo 優化表單元件

11.5.2 使用者體驗

  1. 提供即時驗證回饋
  2. 清楚的錯誤訊息
  3. 載入狀態指示
  4. 適當的表單標籤和 placeholder
  5. 鍵盤導航支援

11.5.3 無障礙設計

function AccessibleForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: ''
  });
  const [errors, setErrors] = useState({});

  return (
    <form>
      <div>
        <label htmlFor="name" id="name-label">
          姓名 <span aria-label="必填欄位">*</span>
        </label>
        <input
          type="text"
          id="name"
          name="name"
          value={formData.name}
          onChange={handleChange}
          aria-labelledby="name-label"
          aria-describedby={errors.name ? "name-error" : undefined}
          aria-invalid={errors.name ? "true" : "false"}
          required
        />
        {errors.name && (
          <div id="name-error" role="alert" style={{ color: '#f44336' }}>
            {errors.name}
          </div>
        )}
      </div>
    </form>
  );
}

11.6 總結

表單處理是 React 應用程式中的重要主題,涉及使用者互動、資料驗證和狀態管理等多個層面。

本章重點:

  • 受控 vs 不受控元件:理解兩種表單處理方式的差異和適用場景
  • 不同表單元素:掌握文字輸入、選擇元素、檔案上傳的處理方法
  • 表單驗證:實現即時和提交時的驗證機制
  • 複雜表單:多步驟表單和動態表單的實現
  • 自訂 Hook:抽象表單邏輯,提高程式碼重用性
  • 最佳實踐:效能優化、使用者體驗和無障礙設計

選擇表單處理方式的建議:

  1. 大多數情況下使用受控元件:更好的控制和驗證
  2. 簡單表單可考慮不受控元件:減少重新渲染
  3. 複雜表單使用自訂 Hook:提高程式碼組織性
  4. 考慮使用第三方庫:如 Formik、React Hook Form,降低開發複雜度

表單設計原則:

  1. 保持簡潔:避免過多的必填欄位
  2. 提供清晰的指導:使用 placeholder 和說明文字
  3. 即時回饋:讓使用者知道輸入是否正確
  4. 錯誤處理:友善的錯誤訊息和恢復機制
  5. 無障礙設計:支援螢幕閱讀器和鍵盤操作

掌握表單處理技能對於建立優秀的使用者互動體驗至關重要。在下一章中,我們將學習 Context API,這是 React 中處理跨元件狀態共享的重要工具。

results matching ""

    No results matching ""