9.1 受控元件 (Controlled Components)

在受控元件中,表單的值由 React 的 state 控制。每次使用者輸入,都透過 onChange 更新 state,再由 state 驅動 UI。

import { useState } from 'react';

function ControlledForm() {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({ username, email });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">帳號:</label>
        <input
          id="username"
          value={username}               // 由 state 控制
          onChange={(e) => setUsername(e.target.value)}  // 更新 state
        />
      </div>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>
      <button type="submit">送出</button>
    </form>
  );
}

用一個 state 物件管理多個欄位

import { useState } from 'react';

function UnifiedForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    age: '',
    gender: '',
    country: 'TW',
    bio: '',
    newsletter: false,
    interests: [],
  });

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

    if (type === 'checkbox' && name === 'interests') {
      setFormData((prev) => ({
        ...prev,
        interests: checked
          ? [...prev.interests, value]
          : prev.interests.filter((i) => i !== value),
      }));
    } else {
      setFormData((prev) => ({
        ...prev,
        [name]: type === 'checkbox' ? checked : value,
      }));
    }
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('表單資料:', formData);
  };

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '500px', margin: '20px auto' }}>
      <h2>使用者資料表單</h2>

      {/* 文字輸入 */}
      <div style={{ marginBottom: '16px' }}>
        <label>名字:</label>
        <input name="firstName" value={formData.firstName} onChange={handleChange} />
      </div>

      <div style={{ marginBottom: '16px' }}>
        <label>姓氏:</label>
        <input name="lastName" value={formData.lastName} onChange={handleChange} />
      </div>

      <div style={{ marginBottom: '16px' }}>
        <label>Email:</label>
        <input name="email" type="email" value={formData.email} onChange={handleChange} />
      </div>

      <div style={{ marginBottom: '16px' }}>
        <label>年齡:</label>
        <input name="age" type="number" value={formData.age} onChange={handleChange} />
      </div>

      {/* Radio */}
      <fieldset style={{ marginBottom: '16px' }}>
        <legend>性別:</legend>
        {['male', 'female', 'other'].map((g) => (
          <label key={g} style={{ marginRight: '16px' }}>
            <input
              type="radio"
              name="gender"
              value={g}
              checked={formData.gender === g}
              onChange={handleChange}
            />
            {g === 'male' ? '男' : g === 'female' ? '女' : '其他'}
          </label>
        ))}
      </fieldset>

      {/* Select */}
      <div style={{ marginBottom: '16px' }}>
        <label>國家:</label>
        <select name="country" value={formData.country} onChange={handleChange}>
          <option value="TW">台灣</option>
          <option value="JP">日本</option>
          <option value="US">美國</option>
          <option value="KR">韓國</option>
        </select>
      </div>

      {/* Textarea */}
      <div style={{ marginBottom: '16px' }}>
        <label>自我介紹:</label>
        <textarea
          name="bio"
          value={formData.bio}
          onChange={handleChange}
          rows={4}
          style={{ width: '100%' }}
        />
      </div>

      {/* Checkbox - 單一 */}
      <div style={{ marginBottom: '16px' }}>
        <label>
          <input
            type="checkbox"
            name="newsletter"
            checked={formData.newsletter}
            onChange={handleChange}
          />
          訂閱電子報
        </label>
      </div>

      {/* Checkbox - 多選 */}
      <fieldset style={{ marginBottom: '16px' }}>
        <legend>興趣(可多選):</legend>
        {['coding', 'music', 'sports', 'travel'].map((interest) => (
          <label key={interest} style={{ display: 'block', marginBottom: '4px' }}>
            <input
              type="checkbox"
              name="interests"
              value={interest}
              checked={formData.interests.includes(interest)}
              onChange={handleChange}
            />
            {interest === 'coding' ? '程式設計' :
             interest === 'music' ? '音樂' :
             interest === 'sports' ? '運動' : '旅行'}
          </label>
        ))}
      </fieldset>

      <button type="submit">送出</button>

      {/* 即時預覽 */}
      <pre style={{ marginTop: '20px', padding: '12px', backgroundColor: '#f7fafc', borderRadius: '8px' }}>
        {JSON.stringify(formData, null, 2)}
      </pre>
    </form>
  );
}

9.2 非受控元件 (Uncontrolled Components)

使用 useRef 直接存取 DOM 元素的值,React 不管理其狀態。

import { useRef } from 'react';

function UncontrolledForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const fileRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('名字:', nameRef.current.value);
    console.log('Email:', emailRef.current.value);
    console.log('檔案:', fileRef.current.files[0]);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>名字:</label>
        <input ref={nameRef} defaultValue="預設名字" />
      </div>
      <div>
        <label>Email:</label>
        <input ref={emailRef} type="email" />
      </div>
      <div>
        <label>上傳檔案:</label>
        {/* 檔案輸入一定是非受控的 */}
        <input ref={fileRef} type="file" />
      </div>
      <button type="submit">送出</button>
    </form>
  );
}

受控 vs 非受控比較

比較 受控元件 非受控元件
值的來源 React state DOM 本身
即時存取值 ✅ 隨時可用 ❌ 需要 ref
即時驗證 ✅ 容易 ❌ 困難
動態 UI 聯動 ✅ 容易 ❌ 困難
程式碼量 較多 較少
效能 每次輸入都渲染 不觸發渲染
適用場景 大多數情況 簡單表單、檔案上傳

9.3 表單驗證

import { useState } from 'react';

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

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

  const validate = (data) => {
    const newErrors = {};

    if (!data.username.trim()) {
      newErrors.username = '使用者名稱為必填';
    } else if (data.username.length < 3) {
      newErrors.username = '使用者名稱至少 3 個字元';
    }

    if (!data.email.trim()) {
      newErrors.email = 'Email 為必填';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
      newErrors.email = 'Email 格式不正確';
    }

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

    if (data.password !== data.confirmPassword) {
      newErrors.confirmPassword = '密碼不一致';
    }

    return newErrors;
  };

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

    if (touched[name]) {
      setErrors(validate(newFormData));
    }
  };

  const handleBlur = (e) => {
    const { name } = e.target;
    setTouched((prev) => ({ ...prev, [name]: true }));
    setErrors(validate(formData));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const allTouched = Object.keys(formData).reduce(
      (acc, key) => ({ ...acc, [key]: true }),
      {}
    );
    setTouched(allTouched);

    const validationErrors = validate(formData);
    setErrors(validationErrors);

    if (Object.keys(validationErrors).length === 0) {
      alert('表單驗證通過!');
      console.log('提交資料:', formData);
    }
  };

  const inputStyle = (field) => ({
    width: '100%',
    padding: '10px 14px',
    border: `2px solid ${touched[field] && errors[field] ? '#e53e3e' : '#e2e8f0'}`,
    borderRadius: '8px',
    fontSize: '1rem',
    boxSizing: 'border-box',
    outline: 'none',
  });

  const isFormValid = Object.keys(validate(formData)).length === 0;

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '450px', margin: '40px auto', fontFamily: 'sans-serif' }}>
      <h2>註冊表單</h2>

      {['username', 'email', 'password', 'confirmPassword'].map((field) => (
        <div key={field} style={{ marginBottom: '20px' }}>
          <label style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
            {field === 'username' && '使用者名稱'}
            {field === 'email' && 'Email'}
            {field === 'password' && '密碼'}
            {field === 'confirmPassword' && '確認密碼'}
          </label>
          <input
            name={field}
            type={field.includes('password') || field.includes('Password') ? 'password' : field === 'email' ? 'email' : 'text'}
            value={formData[field]}
            onChange={handleChange}
            onBlur={handleBlur}
            style={inputStyle(field)}
          />
          {touched[field] && errors[field] && (
            <p style={{ color: '#e53e3e', fontSize: '0.85rem', margin: '4px 0 0' }}>
              {errors[field]}
            </p>
          )}
        </div>
      ))}

      {/* 密碼強度指示 */}
      {formData.password && (
        <div style={{ marginBottom: '20px' }}>
          <p style={{ fontSize: '0.85rem', color: '#718096' }}>密碼強度:</p>
          <div style={{ display: 'flex', gap: '4px' }}>
            {[1, 2, 3, 4].map((level) => {
              const strength = [
                formData.password.length >= 8,
                /[a-z]/.test(formData.password),
                /[A-Z]/.test(formData.password),
                /\d/.test(formData.password),
              ].filter(Boolean).length;

              return (
                <div
                  key={level}
                  style={{
                    flex: 1,
                    height: '4px',
                    borderRadius: '2px',
                    backgroundColor: level <= strength
                      ? strength <= 1 ? '#e53e3e'
                      : strength <= 2 ? '#ed8936'
                      : strength <= 3 ? '#ecc94b'
                      : '#48bb78'
                    : '#e2e8f0',
                  }}
                />
              );
            })}
          </div>
        </div>
      )}

      <button
        type="submit"
        disabled={!isFormValid}
        style={{
          width: '100%',
          padding: '12px',
          backgroundColor: isFormValid ? '#4299e1' : '#cbd5e0',
          color: 'white',
          border: 'none',
          borderRadius: '8px',
          fontSize: '1rem',
          fontWeight: 'bold',
          cursor: isFormValid ? 'pointer' : 'not-allowed',
        }}
      >
        註冊
      </button>
    </form>
  );
}

export default ValidatedForm;

9.4 練習題

練習 1:建立一個問卷表單

包含文字、Radio、Checkbox、Select、Range 等多種輸入元素,並在提交時顯示結果摘要。

練習 2:建立一個動態表單產生器

接收 JSON 設定,動態生成對應的表單欄位(類似 Google Forms)。


本課重點回顧

  1. 受控元件:值由 state 控制,透過 onChange 更新
  2. 非受控元件:使用 ref 存取 DOM 值,適合簡單場景
  3. 使用 e.preventDefault() 阻止表單提交的頁面重載
  4. 多欄位可用一個 state 物件 + 動態 [name] 屬性管理
  5. 驗證要考慮:即時驗證、失焦驗證、提交驗證
  6. 檔案上傳一定是非受控元件