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)。
本課重點回顧
- 受控元件:值由 state 控制,透過
onChange更新 - 非受控元件:使用
ref存取 DOM 值,適合簡單場景 - 使用
e.preventDefault()阻止表單提交的頁面重載 - 多欄位可用一個 state 物件 + 動態
[name]屬性管理 - 驗證要考慮:即時驗證、失焦驗證、提交驗證
- 檔案上傳一定是非受控元件