第 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 效能優化
- 使用 useCallback 穩定事件處理函式
- 避免在每次渲染時創建新的物件
- 考慮使用 React.memo 優化表單元件
11.5.2 使用者體驗
- 提供即時驗證回饋
- 清楚的錯誤訊息
- 載入狀態指示
- 適當的表單標籤和 placeholder
- 鍵盤導航支援
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:抽象表單邏輯,提高程式碼重用性
- 最佳實踐:效能優化、使用者體驗和無障礙設計
選擇表單處理方式的建議:
- 大多數情況下使用受控元件:更好的控制和驗證
- 簡單表單可考慮不受控元件:減少重新渲染
- 複雜表單使用自訂 Hook:提高程式碼組織性
- 考慮使用第三方庫:如 Formik、React Hook Form,降低開發複雜度
表單設計原則:
- 保持簡潔:避免過多的必填欄位
- 提供清晰的指導:使用 placeholder 和說明文字
- 即時回饋:讓使用者知道輸入是否正確
- 錯誤處理:友善的錯誤訊息和恢復機制
- 無障礙設計:支援螢幕閱讀器和鍵盤操作
掌握表單處理技能對於建立優秀的使用者互動體驗至關重要。在下一章中,我們將學習 Context API,這是 React 中處理跨元件狀態共享的重要工具。