📅 NGÀY 13: Forms với State
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Phân biệt Controlled vs Uncontrolled components và biết khi nào dùng approach nào
- [ ] Xử lý multiple form inputs một cách hiệu quả với state structure hợp lý
- [ ] Implement form validation realtime và on-submit với useState
- [ ] Handle form submission đúng cách (prevent default, clear form, error handling)
- [ ] Optimize form performance với các patterns từ Ngày 12
🤔 Kiểm tra đầu vào (5 phút)
Trả lời 3 câu hỏi sau để kích hoạt kiến thức từ Ngày 11-12:
- Câu 1: Code này có vấn đề gì?
const [formData, setFormData] = useState({ email: '' });
const handleChange = (e) => {
formData.email = e.target.value; // ❓
setFormData(formData);
};Câu 2: Khi nào nên dùng functional updates
setState(prev => ...)?Câu 3: Tại sao không nên store
isEmailValidtrong state nếu có thể validate từemail?
💡 Xem đáp án
- Mutation! Phải dùng immutable update:
setFormData(prev => ({...prev, email: e.target.value})) - Khi update dựa trên previous value, trong async operations, hoặc multiple updates
- Derived state anti-pattern!
isEmailValidcó thể compute từemail→ không cần store riêng
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Forms là phần KHẮC NGHIỆT NHẤT của React cho beginners. Tại sao?
// HTML form truyền thống
<form>
<input name="email" /> {/* Browser tự quản lý value */}
<button type="submit">Submit</button>
</form>
// React form - PHẢI quản lý state
<form>
<input
value={email} // ❓ Lấy từ đâu?
onChange={handleChange} // ❓ Làm gì?
/>
<button type="submit">Submit</button>
</form>Challenges:
- 🤔 Form state nên ở đâu?
- 🤔 Làm sao sync input value với state?
- 🤔 Validate khi nào? (onChange, onBlur, onSubmit?)
- 🤔 Handle errors thế nào?
- 🤔 Multiple inputs → nhiều states hay một object?
Hôm nay sẽ giải quyết TẤT CẢ những câu hỏi này!
1.2 Giải Pháp: Controlled Components
Core Concept:
┌─────────────────────────────────────────┐
│ CONTROLLED COMPONENT │
├─────────────────────────────────────────┤
│ │
│ React State = Single Source of Truth │
│ │
│ User types → onChange → setState │
│ ↓ │
│ State updates → Re-render │
│ ↓ │
│ Input value = state value │
│ │
│ ┌──────────┐ │
│ │ State │ ←───────────┐ │
│ │ email │ │ │
│ └────┬─────┘ │ │
│ │ │ │
│ ↓ │ │
│ <input │ │
│ value={email} │ │
│ onChange={e => ────────┘ │
│ setEmail(e.target.value) │
│ } │
│ /> │
└─────────────────────────────────────────┘React controls the input, not the browser!
1.3 Mental Model
Hãy tưởng tượng form như dashboard với live updates:
Form truyền thống (Uncontrolled):
Người dùng nhập → Trình duyệt lưu → Submit → Đọc toàn bộ một lần
[Giống như viết trên giấy → nộp khi xong]
Form React (Controlled):
Người dùng nhập → State React cập nhật → UI cập nhật ngay lập tức
[Giống như bảng điều khiển trực tiếp → thấy thay đổi theo thời gian thực]Analogy:
- Uncontrolled: Gửi thư qua bưu điện (submit mới biết nội dung)
- Controlled: Nhắn tin realtime (thấy từng ký tự ngay lập tức)
1.4 Hiểu Lầm Phổ Biến
❌ Myth 1: "Controlled components phức tạp hơn, không dùng được không?"
✅ Truth: 95% cases nên dùng controlled. Uncontrolled chỉ cho edge cases.
❌ Myth 2: "Mỗi input cần 1 state riêng"
✅ Truth: Nên group related inputs vào 1 object (Ngày 12!)
❌ Myth 3: "Validate chỉ khi submit"
✅ Truth: Best UX là realtime validation + final check on submit
❌ Myth 4: "Forms trong React khó hơn HTML"
✅ Truth: Khó hơn ban đầu, nhưng powerful hơn NHIỀU (validation, dynamic fields, etc.)
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Controlled vs Uncontrolled - Pattern Cơ Bản ⭐
❌ Uncontrolled Component (Not Recommended)
// ❌ Browser controls the input value
function UncontrolledForm() {
const handleSubmit = (e) => {
e.preventDefault();
// ❌ Phải query DOM để lấy value
const email = e.target.elements.email.value;
const password = e.target.elements.password.value;
console.log('Submitted:', { email, password });
// ❌ Không biết value trước khi submit
// ❌ Không validate được realtime
// ❌ Không disable submit button khi invalid
};
return (
<form onSubmit={handleSubmit}>
<input
name='email'
type='email'
// ❌ Không có value prop → Browser controls
/>
<input
name='password'
type='password'
/>
<button type='submit'>Submit</button>
</form>
);
}Problems:
- ❌ Không biết user đang gõ gì
- ❌ Không validate được realtime
- ❌ Không disable submit khi invalid
- ❌ Khó dynamic UI (show/hide fields)
- ❌ Khó auto-format (phone number, credit card)
✅ Controlled Component (Recommended)
// ✅ React controls the input value
function ControlledForm() {
// ✅ State = Single Source of Truth
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
// ✅ Đã có value từ state
console.log('Submitted:', { email, password });
// ✅ Clear form sau khi submit
setEmail('');
setPassword('');
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type='email'
value={email} // ✅ React controls value
onChange={(e) => setEmail(e.target.value)}
/>
<p>You typed: {email}</p> {/* ✅ Can use state anywhere */}
</div>
<div>
<label>Password:</label>
<input
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p>Length: {password.length}</p> {/* ✅ Realtime info */}
</div>
<button
type='submit'
disabled={!email || password.length < 6} // ✅ Smart button!
>
Submit
</button>
</form>
);
}Benefits:
- ✅ Biết user đang gõ gì (realtime)
- ✅ Validate ngay khi gõ
- ✅ Disable submit khi invalid
- ✅ Hiển thị UI hữu ích (độ mạnh mật khẩu, định dạng email)
- ✅ Dễ dàng biến đổi dữ liệu nhập (viết hoa, định dạng, v.v.)
Demo 2: Multiple Inputs với Object State - Kịch Bản Thực Tế ⭐⭐
❌ CÁCH SAI: Mỗi Input 1 State Riêng
// ❌ Code dài dòng, khó maintain
function SignupFormBad() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [phone, setPhone] = useState('');
const [address, setAddress] = useState('');
const [city, setCity] = useState('');
const [country, setCountry] = useState('');
// 😱 9 states cho 1 form!
const handleSubmit = (e) => {
e.preventDefault();
// ❌ Phải list tất cả fields
const formData = {
firstName,
lastName,
email,
password,
confirmPassword,
phone,
address,
city,
country,
};
console.log(formData);
// ❌ Reset form = 9 dòng code!
setFirstName('');
setLastName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setPhone('');
setAddress('');
setCity('');
setCountry('');
};
return (
<form onSubmit={handleSubmit}>
<input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
<input
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
{/* ...7 inputs nữa với 7 onChange handlers! */}
</form>
);
}Problems:
- 😱 Too many states
- 😱 Too many onChange handlers
- 😱 Reset form phức tạp
- 😱 Hard to validate related fields
- 😱 Can't easily pass to API
✅ CÁCH ĐÚNG: Single Object State
// ✅ Clean, maintainable, scalable
function SignupFormGood() {
// ✅ Group related data (apply Ngày 12!)
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
phone: '',
address: '',
city: '',
country: '',
});
// ✅ Generic handler cho tất cả inputs
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev, // Keep old values
[name]: value, // Update changed field
}));
};
const handleSubmit = (e) => {
e.preventDefault();
// ✅ formData đã sẵn sàng để gửi API
console.log('Submitting:', formData);
// ✅ Reset form = 1 dòng!
setFormData({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
phone: '',
address: '',
city: '',
country: '',
});
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>First Name:</label>
<input
name='firstName' // ⚠️ IMPORTANT: name attribute!
value={formData.firstName}
onChange={handleChange} // ✅ Reuse same handler
/>
</div>
<div>
<label>Last Name:</label>
<input
name='lastName'
value={formData.lastName}
onChange={handleChange}
/>
</div>
<div>
<label>Email:</label>
<input
name='email'
type='email'
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label>Password:</label>
<input
name='password'
type='password'
value={formData.password}
onChange={handleChange}
/>
</div>
<div>
<label>Confirm Password:</label>
<input
name='confirmPassword'
type='password'
value={formData.confirmPassword}
onChange={handleChange}
/>
</div>
{/* ...more inputs... */}
<button type='submit'>Sign Up</button>
{/* Debug view */}
<details>
<summary>Form Data</summary>
<pre>{JSON.stringify(formData, null, 2)}</pre>
</details>
</form>
);
}🔥 KEY PATTERN:
// Magic pattern: 1 handler for ALL inputs!
const handleChange = (e) => {
const { name, value } = e.target; // Destructure
setFormData((prev) => ({
...prev, // Immutable update (Ngày 12!)
[name]: value, // Computed property name
}));
};
// Each input needs `name` attribute matching state key
<input
name='email' // Must match formData.email
value={formData.email}
onChange={handleChange}
/>;Demo 3: Validation & Error Handling - Edge Cases ⭐⭐⭐
// ✅ Production-ready form with validation
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
age: '',
});
// ✅ Separate state for errors
const [errors, setErrors] = useState({});
// ✅ Track which fields user has touched
const [touched, setTouched] = useState({});
// ✅ Validation rules (pure functions)
const validators = {
username: (value) => {
if (!value) return 'Username is required';
if (value.length < 3) return 'Username must be at least 3 characters';
if (!/^[a-zA-Z0-9_]+$/.test(value))
return 'Only letters, numbers, underscore';
return ''; // No error
},
email: (value) => {
if (!value) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
return 'Invalid email format';
return '';
},
password: (value) => {
if (!value) return 'Password is required';
if (value.length < 8) return 'Password must be at least 8 characters';
if (!/[A-Z]/.test(value)) return 'Password must contain uppercase letter';
if (!/[0-9]/.test(value)) return 'Password must contain number';
return '';
},
confirmPassword: (value) => {
if (!value) return 'Please confirm password';
if (value !== formData.password) return 'Passwords do not match';
return '';
},
age: (value) => {
if (!value) return 'Age is required';
const num = Number(value);
if (isNaN(num)) return 'Age must be a number';
if (num < 13) return 'Must be at least 13 years old';
if (num > 120) return 'Invalid age';
return '';
},
};
// ✅ Validate single field
const validateField = (name, value) => {
if (validators[name]) {
return validators[name](value);
}
return '';
};
// ✅ Validate all fields
const validateAll = () => {
const newErrors = {};
Object.keys(formData).forEach((field) => {
const error = validateField(field, formData[field]);
if (error) {
newErrors[field] = error;
}
});
return newErrors;
};
// ✅ Handle input change with validation
const handleChange = (e) => {
const { name, value } = e.target;
// Update form data
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error for this field when user types
if (errors[name]) {
setErrors((prev) => ({
...prev,
[name]: '',
}));
}
// Validate confirmPassword when password changes
if (name === 'password' && formData.confirmPassword) {
const confirmError = validators.confirmPassword(formData.confirmPassword);
setErrors((prev) => ({
...prev,
confirmPassword: confirmError,
}));
}
};
// ✅ Handle blur (when user leaves field)
const handleBlur = (e) => {
const { name, value } = e.target;
// Mark field as touched
setTouched((prev) => ({
...prev,
[name]: true,
}));
// Validate field
const error = validateField(name, value);
if (error) {
setErrors((prev) => ({
...prev,
[name]: error,
}));
}
};
// ✅ Handle submit
const handleSubmit = (e) => {
e.preventDefault();
// Mark all fields as touched
const allTouched = Object.keys(formData).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
setTouched(allTouched);
// Validate all
const validationErrors = validateAll();
if (Object.keys(validationErrors).length > 0) {
// Has errors
setErrors(validationErrors);
console.log('Form has errors:', validationErrors);
return;
}
// ✅ Form is valid!
console.log('Form submitted successfully:', formData);
// Clear form
setFormData({
username: '',
email: '',
password: '',
confirmPassword: '',
age: '',
});
setErrors({});
setTouched({});
};
// ✅ Derived: Is form valid?
const isFormValid =
Object.keys(validateAll()).length === 0 &&
Object.keys(formData).every((key) => formData[key]);
return (
<form
onSubmit={handleSubmit}
style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}
>
<h2>Registration Form</h2>
{/* Username */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Username:
</label>
<input
name='username'
value={formData.username}
onChange={handleChange}
onBlur={handleBlur}
style={{
width: '100%',
padding: '8px',
border:
errors.username && touched.username
? '2px solid red'
: '1px solid #ccc',
}}
/>
{touched.username && errors.username && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.username}
</p>
)}
</div>
{/* Email */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>Email:</label>
<input
name='email'
type='email'
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
style={{
width: '100%',
padding: '8px',
border:
errors.email && touched.email
? '2px solid red'
: '1px solid #ccc',
}}
/>
{touched.email && errors.email && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.email}
</p>
)}
</div>
{/* Password */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Password:
</label>
<input
name='password'
type='password'
value={formData.password}
onChange={handleChange}
onBlur={handleBlur}
style={{
width: '100%',
padding: '8px',
border:
errors.password && touched.password
? '2px solid red'
: '1px solid #ccc',
}}
/>
{touched.password && errors.password && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.password}
</p>
)}
{/* Password strength indicator */}
{formData.password && (
<div style={{ marginTop: '5px', fontSize: '0.85em' }}>
<span
style={{
color: formData.password.length >= 8 ? 'green' : 'gray',
}}
>
✓ 8+ characters
</span>
{' | '}
<span
style={{
color: /[A-Z]/.test(formData.password) ? 'green' : 'gray',
}}
>
✓ Uppercase
</span>
{' | '}
<span
style={{
color: /[0-9]/.test(formData.password) ? 'green' : 'gray',
}}
>
✓ Number
</span>
</div>
)}
</div>
{/* Confirm Password */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Confirm Password:
</label>
<input
name='confirmPassword'
type='password'
value={formData.confirmPassword}
onChange={handleChange}
onBlur={handleBlur}
style={{
width: '100%',
padding: '8px',
border:
errors.confirmPassword && touched.confirmPassword
? '2px solid red'
: '1px solid #ccc',
}}
/>
{touched.confirmPassword && errors.confirmPassword && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.confirmPassword}
</p>
)}
</div>
{/* Age */}
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>Age:</label>
<input
name='age'
type='number'
value={formData.age}
onChange={handleChange}
onBlur={handleBlur}
style={{
width: '100%',
padding: '8px',
border:
errors.age && touched.age ? '2px solid red' : '1px solid #ccc',
}}
/>
{touched.age && errors.age && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.age}
</p>
)}
</div>
{/* Submit Button */}
<button
type='submit'
disabled={!isFormValid}
style={{
width: '100%',
padding: '12px',
background: isFormValid ? '#4CAF50' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isFormValid ? 'pointer' : 'not-allowed',
fontSize: '16px',
}}
>
Register
</button>
{/* Debug */}
<details style={{ marginTop: '20px' }}>
<summary>Debug Info</summary>
<div style={{ fontSize: '0.85em' }}>
<p>
<strong>Form Data:</strong>
</p>
<pre>{JSON.stringify(formData, null, 2)}</pre>
<p>
<strong>Errors:</strong>
</p>
<pre>{JSON.stringify(errors, null, 2)}</pre>
<p>
<strong>Touched:</strong>
</p>
<pre>{JSON.stringify(touched, null, 2)}</pre>
<p>
<strong>Is Valid:</strong> {isFormValid ? 'Yes' : 'No'}
</p>
</div>
</details>
</form>
);
}🔥 KEY PATTERNS:
- Tách riêng state lỗi:
const [formData, setFormData] = useState({...});
const [errors, setErrors] = useState({}); // ✅ Tách riêng!
const [touched, setTouched] = useState({}); // ✅ Theo dõi field đã chạm- Thời điểm validate:
onChange → Xoá lỗi (UX tốt - không hiện lỗi khi đang gõ)
onBlur → Validate field (hiện lỗi khi người dùng rời field)
onSubmit → Validate tất cả (kiểm tra cuối cùng)- Validate giữa các field:
// Khi password thay đổi, validate lại confirmPassword
if (name === 'password' && formData.confirmPassword) {
validateField('confirmPassword', formData.confirmPassword);
}🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Exercise 1: Basic Login Form (15 phút)
/**
* 🎯 Mục tiêu: Tạo controlled login form cơ bản
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: useEffect, useRef, external libraries
*
* Requirements:
* 1. 2 inputs: email và password (controlled)
* 2. Submit button disabled khi email rỗng HOẶC password < 6 ký tự
* 3. onSubmit: console.log form data và clear form
* 4. Hiển thị "Password length: X" khi user gõ password
*
* 💡 Gợi ý:
* - Dùng 2 separate states hoặc 1 object state (bạn chọn!)
* - e.preventDefault() trong handleSubmit
*/
// ❌ Starter code (có bugs):
function LoginForm() {
// TODO: Add state
const handleSubmit = (e) => {
// TODO: Implement
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input type='email' />
{/* TODO: Make controlled */}
</div>
<div>
<label>Password:</label>
<input type='password' />
{/* TODO: Make controlled */}
{/* TODO: Show length */}
</div>
<button type='submit'>
{/* TODO: Disable when invalid */}
Login
</button>
</form>
);
}
// ✅ NHIỆM VỤ CỦA BẠN:
// TODO: Implement controlled inputs
// TODO: Add state management
// TODO: Implement validation
// TODO: Handle submit💡 Solution
function LoginForm() {
// Option 1: Separate states (simple form)
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Option 2: Object state (recommended for larger forms)
// const [formData, setFormData] = useState({ email: '', password: '' });
const handleSubmit = (e) => {
e.preventDefault(); // ✅ Prevent page reload
console.log('Login submitted:', { email, password });
// Clear form
setEmail('');
setPassword('');
};
// Validation logic
const isValid = email.trim() !== '' && password.length >= 6;
return (
<form
onSubmit={handleSubmit}
style={{ maxWidth: '400px', padding: '20px' }}
>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>Email:</label>
<input
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder='you@example.com'
style={{ width: '100%', padding: '8px' }}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Password:
</label>
<input
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder='Min 6 characters'
style={{ width: '100%', padding: '8px' }}
/>
{password && (
<p
style={{
fontSize: '0.85em',
color: password.length >= 6 ? 'green' : 'red',
}}
>
Password length: {password.length}{' '}
{password.length >= 6 ? '✓' : '(min 6)'}
</p>
)}
</div>
<button
type='submit'
disabled={!isValid}
style={{
width: '100%',
padding: '10px',
background: isValid ? '#007bff' : '#ccc',
color: 'white',
border: 'none',
cursor: isValid ? 'pointer' : 'not-allowed',
}}
>
Login
</button>
{/* Debug */}
<div style={{ marginTop: '20px', fontSize: '0.85em', color: '#666' }}>
<p>Email: {email || '(empty)'}</p>
<p>Password: {'*'.repeat(password.length)}</p>
<p>Valid: {isValid ? 'Yes ✓' : 'No ✗'}</p>
</div>
</form>
);
}⭐⭐ Exercise 2: Contact Form with Multiple Inputs (25 phút)
/**
* 🎯 Mục tiêu: Handle multiple inputs efficiently
* ⏱️ Thời gian: 25 phút
*
* Scenario: Contact form với nhiều fields
*
* 🤔 PHÂN TÍCH:
* Approach A: Mỗi field 1 state riêng
* Pros: Đơn giản, rõ ràng
* Cons: Nhiều states, nhiều onChange handlers
*
* Approach B: Object state + generic handler
* Pros: Scalable, ít code hơn, easy to extend
* Cons: Phức tạp hơn chút (nhưng đáng!)
*
* 💭 BẠN CHỌN GÌ VÀ TẠI SAO?
*/
// Requirements:
const FIELDS = {
name: { label: 'Full Name', type: 'text', required: true },
email: { label: 'Email', type: 'email', required: true },
phone: { label: 'Phone', type: 'tel', required: false },
subject: { label: 'Subject', type: 'text', required: true },
message: { label: 'Message', type: 'textarea', required: true },
};
// ✅ NHIỆM VỤ CỦA BẠN:
function ContactForm() {
// TODO: Design state structure (justify your choice!)
// TODO: Implement generic handleChange
const handleChange = (e) => {
// Hint: const { name, value } = e.target;
};
// TODO: Implement handleSubmit
const handleSubmit = (e) => {
e.preventDefault();
// 1. Validate required fields
// 2. Console.log if valid
// 3. Clear form
};
// TODO: Validation - check all required fields filled
const isValid = false; // Replace with actual logic
return (
<form onSubmit={handleSubmit}>
{/* TODO: Render inputs dynamically or manually */}
<button
type='submit'
disabled={!isValid}
>
Send Message
</button>
</form>
);
}
// 📝 Document your decision:
/**
* State Structure Decision:
*
* Chosen Approach: [A or B]
*
* Rationale:
* - [Why this approach?]
*
* Trade-offs:
* - [What are you giving up?]
*/💡 Solution
/**
* State Structure Decision:
*
* Chosen Approach: B - Object state + generic handler
*
* Rationale:
* - 5 fields → would need 5 states + 5 handlers in Approach A
* - Generic handler scales to any number of fields
* - Easy to add/remove fields in future
* - Form data already in object shape for API
*
* Trade-offs:
* - Slightly more complex initially
* - Need to understand computed property names
* - Worth it for maintainability!
*/
const FIELDS = {
name: { label: 'Full Name', type: 'text', required: true },
email: { label: 'Email', type: 'email', required: true },
phone: { label: 'Phone', type: 'tel', required: false },
subject: { label: 'Subject', type: 'text', required: true },
message: { label: 'Message', type: 'textarea', required: true },
};
function ContactForm() {
// ✅ Single object state
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
subject: '',
message: '',
});
// ✅ Generic handler for all inputs
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
// ✅ Validate required fields
const validateForm = () => {
// Check all required fields are filled
return Object.keys(FIELDS).every((fieldName) => {
const field = FIELDS[fieldName];
if (field.required) {
return formData[fieldName].trim() !== '';
}
return true; // Optional fields always pass
});
};
const isValid = validateForm();
// ✅ Handle submit
const handleSubmit = (e) => {
e.preventDefault();
if (!isValid) {
console.log('Form is invalid!');
return;
}
console.log('Contact form submitted:', formData);
// Clear form
setFormData({
name: '',
email: '',
phone: '',
subject: '',
message: '',
});
alert('Message sent successfully!');
};
return (
<form
onSubmit={handleSubmit}
style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}
>
<h2>Contact Us</h2>
{/* Render fields dynamically */}
{Object.keys(FIELDS).map((fieldName) => {
const field = FIELDS[fieldName];
const isTextarea = field.type === 'textarea';
return (
<div
key={fieldName}
style={{ marginBottom: '15px' }}
>
<label
style={{
display: 'block',
marginBottom: '5px',
fontWeight: 'bold',
}}
>
{field.label}
{field.required && <span style={{ color: 'red' }}> *</span>}
</label>
{isTextarea ? (
<textarea
name={fieldName}
value={formData[fieldName]}
onChange={handleChange}
rows={5}
style={{ width: '100%', padding: '8px', fontFamily: 'inherit' }}
/>
) : (
<input
type={field.type}
name={fieldName}
value={formData[fieldName]}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
/>
)}
{/* Show character count for message */}
{fieldName === 'message' && formData.message && (
<p
style={{
fontSize: '0.85em',
color: '#666',
margin: '5px 0 0 0',
}}
>
{formData.message.length} characters
</p>
)}
</div>
);
})}
<button
type='submit'
disabled={!isValid}
style={{
width: '100%',
padding: '12px',
background: isValid ? '#28a745' : '#ccc',
color: 'white',
border: 'none',
fontSize: '16px',
cursor: isValid ? 'pointer' : 'not-allowed',
borderRadius: '4px',
}}
>
Send Message
</button>
{/* Missing fields indicator */}
{!isValid && (
<p style={{ color: 'red', marginTop: '10px', fontSize: '0.9em' }}>
Please fill in all required fields (*)
</p>
)}
{/* Debug */}
<details style={{ marginTop: '20px' }}>
<summary>Debug: Form Data</summary>
<pre
style={{ background: '#f5f5f5', padding: '10px', overflow: 'auto' }}
>
{JSON.stringify(formData, null, 2)}
</pre>
</details>
</form>
);
}Key Learnings:
- Dynamic rendering với Object.keys().map()
- Generic handler scales to any number of fields
- Validation logic reusable
- Easy to add new fields - just update FIELDS object!
⭐⭐⭐ Exercise 3: Product Review Form with Validation (40 phút)
/**
* 🎯 Mục tiêu: Form với validation phức tạp
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là customer, tôi muốn review sản phẩm với rating và comment"
*
* ✅ Acceptance Criteria:
* - [ ] Fields: productName, rating (1-5), reviewTitle, reviewText, recommend (yes/no), email
* - [ ] Rating: số nguyên từ 1-5, hiển thị stars ⭐
* - [ ] Review title: 10-100 ký tự
* - [ ] Review text: 50-500 ký tự
* - [ ] Email: valid format
* - [ ] Show errors onBlur (touched fields only)
* - [ ] Character counters cho text fields
* - [ ] Disable submit khi invalid
*
* 🎨 Technical Constraints:
* - Object state cho form data
* - Object state cho errors
* - Object state cho touched
* - Validators object với reusable validation functions
*
* 🚨 Edge Cases cần handle:
* - Empty strings
* - Spaces-only strings (use .trim())
* - Rating out of range
* - Email format invalid
*/
// ✅ NHIỆM VỤ CỦA BẠN:
function ProductReviewForm() {
// TODO: State for form data
const [formData, setFormData] = useState({
productName: '',
rating: '',
reviewTitle: '',
reviewText: '',
recommend: '',
email: '',
});
// TODO: State for errors
const [errors, setErrors] = useState({});
// TODO: State for touched fields
const [touched, setTouched] = useState({});
// TODO: Validation functions
const validators = {
productName: (value) => {
// Required, min 2 chars
},
rating: (value) => {
// Required, 1-5
},
reviewTitle: (value) => {
// Required, 10-100 chars
},
reviewText: (value) => {
// Required, 50-500 chars
},
recommend: (value) => {
// Required
},
email: (value) => {
// Required, valid email
},
};
// TODO: Implement handlers
const handleChange = (e) => {};
const handleBlur = (e) => {};
const handleSubmit = (e) => {};
return (
<form onSubmit={handleSubmit}>{/* TODO: Implement form fields */}</form>
);
}
// 📝 Implementation Checklist:
// - [ ] All validators implemented
// - [ ] handleChange clears errors
// - [ ] handleBlur validates + marks touched
// - [ ] handleSubmit validates all
// - [ ] Star rating display (⭐⭐⭐⭐⭐)
// - [ ] Character counters
// - [ ] Error messages show only for touched fields💡 Full Solution
function ProductReviewForm() {
const [formData, setFormData] = useState({
productName: '',
rating: '',
reviewTitle: '',
reviewText: '',
recommend: '',
email: '',
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [submitted, setSubmitted] = useState(false);
// ✅ Validation functions
const validators = {
productName: (value) => {
if (!value.trim()) return 'Product name is required';
if (value.trim().length < 2)
return 'Product name must be at least 2 characters';
return '';
},
rating: (value) => {
if (!value) return 'Please select a rating';
const num = Number(value);
if (isNaN(num) || num < 1 || num > 5)
return 'Rating must be between 1 and 5';
return '';
},
reviewTitle: (value) => {
if (!value.trim()) return 'Review title is required';
const len = value.trim().length;
if (len < 10) return `Title too short (${len}/10 minimum)`;
if (len > 100) return `Title too long (${len}/100 maximum)`;
return '';
},
reviewText: (value) => {
if (!value.trim()) return 'Review text is required';
const len = value.trim().length;
if (len < 50) return `Review too short (${len}/50 minimum)`;
if (len > 500) return `Review too long (${len}/500 maximum)`;
return '';
},
recommend: (value) => {
if (!value) return 'Please indicate if you would recommend this product';
return '';
},
email: (value) => {
if (!value.trim()) return 'Email is required';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) return 'Invalid email format';
return '';
},
};
// ✅ Validate single field
const validateField = (name, value) => {
if (validators[name]) {
return validators[name](value);
}
return '';
};
// ✅ Validate all fields
const validateAll = () => {
const newErrors = {};
Object.keys(formData).forEach((field) => {
const error = validateField(field, formData[field]);
if (error) newErrors[field] = error;
});
return newErrors;
};
// ✅ Handle input change
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user types
if (errors[name]) {
setErrors((prev) => ({
...prev,
[name]: '',
}));
}
};
// ✅ Handle blur
const handleBlur = (e) => {
const { name, value } = e.target;
setTouched((prev) => ({
...prev,
[name]: true,
}));
const error = validateField(name, value);
if (error) {
setErrors((prev) => ({
...prev,
[name]: error,
}));
}
};
// ✅ Handle submit
const handleSubmit = (e) => {
e.preventDefault();
// Mark all as touched
const allTouched = Object.keys(formData).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
setTouched(allTouched);
// Validate all
const validationErrors = validateAll();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Success!
console.log('Review submitted:', formData);
setSubmitted(true);
// Reset form after 2 seconds
setTimeout(() => {
setFormData({
productName: '',
rating: '',
reviewTitle: '',
reviewText: '',
recommend: '',
email: '',
});
setErrors({});
setTouched({});
setSubmitted(false);
}, 2000);
};
const isFormValid = Object.keys(validateAll()).length === 0;
// Helper: Render stars
const renderStars = (rating) => {
return '⭐'.repeat(Number(rating) || 0);
};
return (
<form
onSubmit={handleSubmit}
style={{ maxWidth: '700px', margin: '0 auto', padding: '20px' }}
>
<h2>📝 Product Review</h2>
{submitted && (
<div
style={{
padding: '15px',
background: '#d4edda',
color: '#155724',
marginBottom: '20px',
borderRadius: '4px',
}}
>
✅ Thank you for your review!
</div>
)}
{/* Product Name */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Product Name *
</label>
<input
name='productName'
value={formData.productName}
onChange={handleChange}
onBlur={handleBlur}
placeholder='e.g., iPhone 15 Pro'
style={{
width: '100%',
padding: '10px',
border:
touched.productName && errors.productName
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{touched.productName && errors.productName && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.productName}
</p>
)}
</div>
{/* Rating */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Rating * {formData.rating && renderStars(formData.rating)}
</label>
<select
name='rating'
value={formData.rating}
onChange={handleChange}
onBlur={handleBlur}
style={{
width: '100%',
padding: '10px',
border:
touched.rating && errors.rating
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
>
<option value=''>Select rating...</option>
<option value='5'>5 - Excellent</option>
<option value='4'>4 - Good</option>
<option value='3'>3 - Average</option>
<option value='2'>2 - Poor</option>
<option value='1'>1 - Terrible</option>
</select>
{touched.rating && errors.rating && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.rating}
</p>
)}
</div>
{/* Review Title */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Review Title *
<span
style={{ fontSize: '0.85em', fontWeight: 'normal', color: '#666' }}
>
{' '}
({formData.reviewTitle.trim().length}/10-100 chars)
</span>
</label>
<input
name='reviewTitle'
value={formData.reviewTitle}
onChange={handleChange}
onBlur={handleBlur}
placeholder='Sum up your review in one line'
maxLength={100}
style={{
width: '100%',
padding: '10px',
border:
touched.reviewTitle && errors.reviewTitle
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{touched.reviewTitle && errors.reviewTitle && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.reviewTitle}
</p>
)}
</div>
{/* Review Text */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Your Review *
<span
style={{ fontSize: '0.85em', fontWeight: 'normal', color: '#666' }}
>
{' '}
({formData.reviewText.trim().length}/50-500 chars)
</span>
</label>
<textarea
name='reviewText'
value={formData.reviewText}
onChange={handleChange}
onBlur={handleBlur}
placeholder='Tell us what you think about this product...'
rows={6}
maxLength={500}
style={{
width: '100%',
padding: '10px',
border:
touched.reviewText && errors.reviewText
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
fontFamily: 'inherit',
}}
/>
{touched.reviewText && errors.reviewText && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.reviewText}
</p>
)}
</div>
{/* Recommend */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}
>
Would you recommend this product? *
</label>
<div>
<label style={{ marginRight: '20px', cursor: 'pointer' }}>
<input
type='radio'
name='recommend'
value='yes'
checked={formData.recommend === 'yes'}
onChange={handleChange}
onBlur={handleBlur}
/>{' '}
Yes
</label>
<label style={{ cursor: 'pointer' }}>
<input
type='radio'
name='recommend'
value='no'
checked={formData.recommend === 'no'}
onChange={handleChange}
onBlur={handleBlur}
/>{' '}
No
</label>
</div>
{touched.recommend && errors.recommend && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.recommend}
</p>
)}
</div>
{/* Email */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Email *{' '}
<span
style={{ fontSize: '0.85em', fontWeight: 'normal', color: '#666' }}
>
(for verification)
</span>
</label>
<input
type='email'
name='email'
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
placeholder='you@example.com'
style={{
width: '100%',
padding: '10px',
border:
touched.email && errors.email
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{touched.email && errors.email && (
<p style={{ color: 'red', fontSize: '0.9em', margin: '5px 0 0 0' }}>
{errors.email}
</p>
)}
</div>
{/* Submit */}
<button
type='submit'
disabled={!isFormValid}
style={{
width: '100%',
padding: '15px',
background: isFormValid ? '#007bff' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
fontWeight: 'bold',
cursor: isFormValid ? 'pointer' : 'not-allowed',
}}
>
Submit Review
</button>
{/* Debug */}
<details style={{ marginTop: '30px' }}>
<summary>🔍 Debug Info</summary>
<div style={{ fontSize: '0.85em' }}>
<p>
<strong>Is Valid:</strong> {isFormValid ? 'Yes ✅' : 'No ❌'}
</p>
<p>
<strong>Form Data:</strong>
</p>
<pre style={{ background: '#f5f5f5', padding: '10px' }}>
{JSON.stringify(formData, null, 2)}
</pre>
<p>
<strong>Errors:</strong>
</p>
<pre style={{ background: '#f5f5f5', padding: '10px' }}>
{JSON.stringify(errors, null, 2)}
</pre>
</div>
</details>
</form>
);
}⭐⭐⭐⭐ Exercise 4: Multi-step Form (60 phút)
/**
* 🎯 Mục tiêu: Multi-step wizard form
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Requirements:
* - 3 steps: Personal Info → Account Details → Preferences
* - Step 1: firstName, lastName, email, phone
* - Step 2: username, password, confirmPassword
* - Step 3: newsletter (yes/no), notifications (yes/no), theme (light/dark)
* - Next button disabled if current step invalid
* - Back button (except step 1)
* - Progress indicator (Step X of 3)
* - Final review before submit
*
* State Design Questions:
* 1. How to track current step?
* 2. How to structure form data (1 object vs 3 objects)?
* 3. How to validate per-step?
* 4. How to prevent going to next step if invalid?
*
* ADR Template:
* - Context: Multi-step form cần track progress + validate per step
* - Decision: State structure của bạn
* - Rationale: Tại sao?
* - Consequences: Trade-offs
*
* 💻 PHASE 2: Implementation (30 phút)
*
* 🧪 PHASE 3: Testing (10 phút)
* Manual test cases
*/
// ✅ NHIỆM VỤ CỦA BẠN:
function MultiStepForm() {
// TODO: Design state
// Consider:
// - currentStep (number)
// - formData (how to structure?)
// - errors per step?
// TODO: Implement step validation
// TODO: Implement navigation (next/back)
// TODO: Render current step conditionally
return (
<div>
{/* Progress indicator */}
{/* Step content */}
{/* Navigation buttons */}
</div>
);
}💡 Solution với ADR
/**
* ADR: Multi-Step Form State Structure
*
* Decision:
* - currentStep: number (1, 2, 3)
* - formData: single flat object với tất cả fields
* - errors: object với keys matching formData
*
* Rationale:
* - Single formData object: dễ submit cuối cùng
* - Flat structure: không cần nested validation
* - Validators per field, check per step
*
* Consequences:
* - Must define which fields belong to which step
* - Validation logic coupled to step definition
* - Trade-off: simplicity vs flexibility (OK for 3 steps)
*/
function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({
// Step 1
firstName: '',
lastName: '',
email: '',
phone: '',
// Step 2
username: '',
password: '',
confirmPassword: '',
// Step 3
newsletter: false,
notifications: false,
theme: 'light',
});
const [errors, setErrors] = useState({});
// Define steps
const STEPS = {
1: {
title: 'Personal Information',
fields: ['firstName', 'lastName', 'email', 'phone'],
},
2: {
title: 'Account Details',
fields: ['username', 'password', 'confirmPassword'],
},
3: {
title: 'Preferences',
fields: ['newsletter', 'notifications', 'theme'],
},
};
// Validators
const validators = {
firstName: (v) => (!v.trim() ? 'First name required' : ''),
lastName: (v) => (!v.trim() ? 'Last name required' : ''),
email: (v) => {
if (!v) return 'Email required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Invalid email';
return '';
},
phone: (v) => {
if (!v) return 'Phone required';
if (!/^\d{10}$/.test(v.replace(/\D/g, '')))
return 'Invalid phone (10 digits)';
return '';
},
username: (v) => {
if (!v) return 'Username required';
if (v.length < 4) return 'Username min 4 chars';
return '';
},
password: (v) => {
if (!v) return 'Password required';
if (v.length < 8) return 'Password min 8 chars';
return '';
},
confirmPassword: (v) => {
if (!v) return 'Please confirm password';
if (v !== formData.password) return 'Passwords do not match';
return '';
},
};
// Validate current step
const validateStep = (step) => {
const stepFields = STEPS[step].fields;
const stepErrors = {};
stepFields.forEach((field) => {
if (validators[field]) {
const error = validators[field](formData[field]);
if (error) stepErrors[field] = error;
}
});
return stepErrors;
};
const isStepValid = () => {
return Object.keys(validateStep(currentStep)).length === 0;
};
// Handlers
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear error
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleNext = () => {
const stepErrors = validateStep(currentStep);
if (Object.keys(stepErrors).length > 0) {
setErrors(stepErrors);
return;
}
if (currentStep < 3) {
setCurrentStep((prev) => prev + 1);
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep((prev) => prev - 1);
}
};
const handleSubmit = (e) => {
e.preventDefault();
// Validate all steps
let allErrors = {};
[1, 2, 3].forEach((step) => {
allErrors = { ...allErrors, ...validateStep(step) };
});
if (Object.keys(allErrors).length > 0) {
console.log('Form has errors:', allErrors);
return;
}
console.log('Form submitted:', formData);
alert('Registration successful!');
// Reset
setFormData({
firstName: '',
lastName: '',
email: '',
phone: '',
username: '',
password: '',
confirmPassword: '',
newsletter: false,
notifications: false,
theme: 'light',
});
setCurrentStep(1);
setErrors({});
};
// Render step content
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<div>
<h3>Personal Information</h3>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
First Name *
</label>
<input
name='firstName'
value={formData.firstName}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
/>
{errors.firstName && (
<p style={{ color: 'red', fontSize: '0.9em' }}>
{errors.firstName}
</p>
)}
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Last Name *
</label>
<input
name='lastName'
value={formData.lastName}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
/>
{errors.lastName && (
<p style={{ color: 'red', fontSize: '0.9em' }}>
{errors.lastName}
</p>
)}
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Email *
</label>
<input
type='email'
name='email'
value={formData.email}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
/>
{errors.email && (
<p style={{ color: 'red', fontSize: '0.9em' }}>
{errors.email}
</p>
)}
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Phone *
</label>
<input
type='tel'
name='phone'
value={formData.phone}
onChange={handleChange}
placeholder='1234567890'
style={{ width: '100%', padding: '8px' }}
/>
{errors.phone && (
<p style={{ color: 'red', fontSize: '0.9em' }}>
{errors.phone}
</p>
)}
</div>
</div>
);
case 2:
return (
<div>
<h3>Account Details</h3>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Username *
</label>
<input
name='username'
value={formData.username}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
/>
{errors.username && (
<p style={{ color: 'red', fontSize: '0.9em' }}>
{errors.username}
</p>
)}
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Password *
</label>
<input
type='password'
name='password'
value={formData.password}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
/>
{errors.password && (
<p style={{ color: 'red', fontSize: '0.9em' }}>
{errors.password}
</p>
)}
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Confirm Password *
</label>
<input
type='password'
name='confirmPassword'
value={formData.confirmPassword}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
/>
{errors.confirmPassword && (
<p style={{ color: 'red', fontSize: '0.9em' }}>
{errors.confirmPassword}
</p>
)}
</div>
</div>
);
case 3:
return (
<div>
<h3>Preferences</h3>
<div style={{ marginBottom: '15px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
}}
>
<input
type='checkbox'
name='newsletter'
checked={formData.newsletter}
onChange={handleChange}
style={{ marginRight: '10px' }}
/>
Subscribe to newsletter
</label>
</div>
<div style={{ marginBottom: '15px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
}}
>
<input
type='checkbox'
name='notifications'
checked={formData.notifications}
onChange={handleChange}
style={{ marginRight: '10px' }}
/>
Enable notifications
</label>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
Theme
</label>
<select
name='theme'
value={formData.theme}
onChange={handleChange}
style={{ width: '100%', padding: '8px' }}
>
<option value='light'>Light</option>
<option value='dark'>Dark</option>
</select>
</div>
{/* Review */}
<div
style={{
background: '#f5f5f5',
padding: '15px',
borderRadius: '4px',
marginTop: '20px',
}}
>
<h4>Review Your Information</h4>
<p>
<strong>Name:</strong> {formData.firstName} {formData.lastName}
</p>
<p>
<strong>Email:</strong> {formData.email}
</p>
<p>
<strong>Phone:</strong> {formData.phone}
</p>
<p>
<strong>Username:</strong> {formData.username}
</p>
<p>
<strong>Newsletter:</strong>{' '}
{formData.newsletter ? 'Yes' : 'No'}
</p>
<p>
<strong>Notifications:</strong>{' '}
{formData.notifications ? 'Yes' : 'No'}
</p>
<p>
<strong>Theme:</strong> {formData.theme}
</p>
</div>
</div>
);
default:
return null;
}
};
return (
<form
onSubmit={handleSubmit}
style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}
>
<h2>Multi-Step Registration</h2>
{/* Progress */}
<div style={{ marginBottom: '30px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '10px',
}}
>
{[1, 2, 3].map((step) => (
<div
key={step}
style={{
flex: 1,
textAlign: 'center',
padding: '10px',
background: currentStep >= step ? '#007bff' : '#e0e0e0',
color: currentStep >= step ? 'white' : '#666',
margin: '0 5px',
borderRadius: '4px',
fontWeight: currentStep === step ? 'bold' : 'normal',
}}
>
Step {step}
</div>
))}
</div>
<p style={{ textAlign: 'center', color: '#666' }}>
{STEPS[currentStep].title}
</p>
</div>
{/* Step Content */}
<div style={{ minHeight: '300px' }}>{renderStep()}</div>
{/* Navigation */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '30px',
}}
>
<button
type='button'
onClick={handleBack}
disabled={currentStep === 1}
style={{
padding: '10px 20px',
background: currentStep === 1 ? '#ccc' : '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: currentStep === 1 ? 'not-allowed' : 'pointer',
}}
>
Back
</button>
{currentStep < 3 ? (
<button
type='button'
onClick={handleNext}
disabled={!isStepValid()}
style={{
padding: '10px 20px',
background: isStepValid() ? '#007bff' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isStepValid() ? 'pointer' : 'not-allowed',
}}
>
Next
</button>
) : (
<button
type='submit'
style={{
padding: '10px 20px',
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Submit
</button>
)}
{/**
* ⚠️ LEARNING PITFALL- Có bẫy ở đoạn code render condition currentStep < 3 vừa rồi:
*
* 1. Hiện tượng: Đang ở Step 2, nhấn "Next", Form lập tức chạy handleSubmit (Step 3 hiện ra rồi biến mất ngay).
* “Tại sao chưa click Submit mà form vẫn submit?”
* 2. Nguyên nhân (Race Condition): React reuse DOM node + event bubbling.
* - Ở Step 2, nút là type="button". Khi click, setCurrentStep(3) được gọi.
* - React render lại cực nhanh. Nó thấy ở vị trí đó vẫn là 1 thẻ <button>, nên nó giữ nguyên DOM node đó
* và chỉ cập nhật thuộc tính type thành "submit" (để sang Step 3).
* - Lúc này, sự kiện "click" từ ngón tay người dùng vẫn chưa kết thúc (vẫn đang trong chu kỳ event loop).
* - Trình duyệt thấy một nút type="submit" vừa bị click -> Kích hoạt onSubmit của thẻ <form>.
*
* 👉 Hãy thử:
* 1. Giữ nguyên code như hiện tại
* 2. Click "Next" từ Step 2
* 3. Quan sát: Step 3 xuất hiện rồi submit ngay
*
* Sau đó, thực hiện 2 cách fix:
* Cách 1. Bỏ onSubmit khỏi <form>, đổi type="submit" ở step 3 thành type="button" và gán onClick cho nó
* -> Cách này React kiểm soát 100% flow, không dính browser default.
* Nhưng mất semantic HTML và người dùng không thể nhấn Enter để gửi form.
*
* Cách 2. Giữ onSubmit, dùng key để tạo sự khác biệt cho DOM node, lúc này 2 nút là riêng biệt không tái sử dụng (reuse)
* Giải quyết đúng gốc rễ việc tái sử dụng DOM của React.
* Người dùng vẫn có thể nhấn Enter ở step cuối để submit (đúng chuẩn HTML). Code chuẩn ngữ nghĩa (semantic) của Form HTML hơn.
* Hiểu thêm về công dụng của key trong Reactjs.
* {currentStep < 3 ? (
<button
key="next-btn" // 👈 Thêm key
type='button'
onClick={handleNext}
>
Next
</button>
) : (
<button
key="submit-btn" // 👈 Key khác hoàn toàn
type='submit'
>
Submit
</button>
)}
*/}
</div>
</form>
);
}⭐⭐⭐⭐⭐ Exercise 5: Dynamic Survey Form (90 phút)
/**
* 🎯 Mục tiêu: Production-ready dynamic survey form
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
* - Survey với questions được define trong config
* - Question types: text, textarea, select, radio, checkbox, number
* - Conditional questions (show Q2 only if Q1 === 'yes')
* - Required vs optional questions
* - Validation per question type
* - Progress bar (X of Y questions answered)
* - Save draft to localStorage
* - Export results as JSON
*
* 🏗️ Technical Design:
* 1. Questions config (array of question objects)
* 2. Answers state (object with question IDs as keys)
* 3. Generic renderer for different question types
* 4. Conditional logic evaluator
* 5. Progress calculator (derived state)
*
* ✅ Production Checklist:
* - [ ] Config-driven (easy to add questions)
* - [ ] Generic question renderer
* - [ ] Conditional logic works
* - [ ] Validation per type
* - [ ] Progress tracking
* - [ ] localStorage persistence
* - [ ] Export functionality
* - [ ] Clear documentation
*/
// Sample survey config
const SURVEY_CONFIG = [
{
id: 'q1',
type: 'radio',
question: 'Have you used React before?',
options: ['Yes', 'No'],
required: true,
},
{
id: 'q2',
type: 'select',
question: 'How long have you been using React?',
options: ['< 6 months', '6-12 months', '1-2 years', '2+ years'],
required: true,
showIf: { questionId: 'q1', answer: 'Yes' }, // Conditional!
},
{
id: 'q3',
type: 'checkbox',
question: 'Which features have you used? (select all)',
options: ['Hooks', 'Context', 'Refs', 'Portals', 'Suspense'],
required: false,
},
{
id: 'q4',
type: 'number',
question: 'Rate React on scale 1-10',
min: 1,
max: 10,
required: true,
},
{
id: 'q5',
type: 'textarea',
question: 'What do you like most about React?',
required: true,
minLength: 20,
},
];
// Implement the survey form!
// Hint: You'll need creative state management for checkboxes (array of selected values)💡 Hint: State Structure
// Suggested state structure
const [answers, setAnswers] = useState({
q1: '', // radio: string
q2: '', // select: string
q3: [], // checkbox: array
q4: '', // number: string (convert when needed)
q5: '', // textarea: string
});
// For conditional rendering, you'll need a function like:
const shouldShowQuestion = (question) => {
if (!question.showIf) return true;
const { questionId, answer } = question.showIf;
return answers[questionId] === answer;
};💡 Solution
import { useState, useEffect } from 'react';
// ────────────────────────────────────────────────
// CONFIG - Dễ dàng thêm/sửa/xoá câu hỏi
// ────────────────────────────────────────────────
const SURVEY_CONFIG = [
{
id: 'q1',
type: 'radio',
question: 'Bạn đã từng sử dụng React trước đây chưa?',
options: ['Có', 'Chưa'],
required: true,
},
{
id: 'q2',
type: 'select',
question: 'Bạn đã sử dụng React được bao lâu?',
options: ['Dưới 6 tháng', '6-12 tháng', '1-2 năm', 'Hơn 2 năm'],
required: true,
showIf: { questionId: 'q1', answer: 'Có' },
},
{
id: 'q3',
type: 'checkbox',
question:
'Bạn đã sử dụng những tính năng nào của React? (chọn tất cả phù hợp)',
options: [
'Hooks',
'Context API',
'useRef',
'Portals',
'Suspense',
'Server Components',
],
required: false,
},
{
id: 'q4',
type: 'number',
question: 'Bạn đánh giá React bao nhiêu điểm trên thang 10?',
min: 1,
max: 10,
required: true,
},
{
id: 'q5',
type: 'textarea',
question: 'Điều bạn thích nhất ở React là gì?',
required: true,
minLength: 20,
maxLength: 500,
},
];
// ────────────────────────────────────────────────
// Helper: Kiểm tra câu hỏi có nên hiển thị không
// ────────────────────────────────────────────────
const shouldShowQuestion = (question, answers) => {
if (!question.showIf) return true;
const { questionId, answer } = question.showIf;
return answers[questionId] === answer;
};
// ────────────────────────────────────────────────
// Helper: Validate một câu trả lời theo config
// ────────────────────────────────────────────────
const validateAnswer = (question, value) => {
if (!question.required && !value) return '';
switch (question.type) {
case 'text':
case 'textarea':
if (question.required && (!value || value.trim() === '')) {
return 'Câu hỏi này là bắt buộc';
}
if (question.minLength && value.trim().length < question.minLength) {
return `Tối thiểu ${question.minLength} ký tự`;
}
if (question.maxLength && value.trim().length > question.maxLength) {
return `Tối đa ${question.maxLength} ký tự`;
}
return '';
case 'number':
if (question.required && !value) return 'Vui lòng nhập số';
const num = Number(value);
if (isNaN(num)) return 'Vui lòng nhập số hợp lệ';
if (question.min != null && num < question.min)
return `Tối thiểu ${question.min}`;
if (question.max != null && num > question.max)
return `Tối đa ${question.max}`;
return '';
case 'radio':
case 'select':
if (question.required && !value) return 'Vui lòng chọn một lựa chọn';
return '';
case 'checkbox':
if (question.required && (!Array.isArray(value) || value.length === 0)) {
return 'Vui lòng chọn ít nhất một lựa chọn';
}
return '';
default:
return '';
}
};
// ────────────────────────────────────────────────
// COMPONENT CHÍNH
// ────────────────────────────────────────────────
function DynamicSurveyForm() {
// State chính lưu câu trả lời
const [answers, setAnswers] = useState(() => {
// Load draft từ localStorage nếu có
const saved = localStorage.getItem('survey-draft');
return saved ? JSON.parse(saved) : {};
});
const [touched, setTouched] = useState({});
const [submitted, setSubmitted] = useState(false);
// Lưu draft mỗi khi answers thay đổi (debounce nếu muốn tối ưu)
useEffect(() => {
localStorage.setItem('survey-draft', JSON.stringify(answers));
}, [answers]);
// ── Derived: Danh sách câu hỏi hiện tại (sau khi lọc conditional)
const visibleQuestions = SURVEY_CONFIG.filter((q) =>
shouldShowQuestion(q, answers),
);
// ── Derived: Tính progress (%)
const answeredCount = visibleQuestions.filter((q) => {
const val = answers[q.id];
if (q.type === 'checkbox') return Array.isArray(val) && val.length > 0;
return val !== undefined && val !== '' && val !== null;
}).length;
const progress = Math.round((answeredCount / visibleQuestions.length) * 100);
// ── Validate toàn bộ form (dùng cho nút Submit)
const getAllErrors = () => {
const errors = {};
visibleQuestions.forEach((q) => {
const error = validateAnswer(q, answers[q.id]);
if (error) errors[q.id] = error;
});
return errors;
};
const errors = getAllErrors();
const isFormValid = Object.keys(errors).length === 0;
// ── Handlers
const handleChange = (questionId, value) => {
setAnswers((prev) => ({
...prev,
[questionId]: value,
}));
// Clear error khi đang gõ
if (touched[questionId]) {
setTouched((prev) => ({ ...prev, [questionId]: false }));
}
};
const handleBlur = (questionId) => {
setTouched((prev) => ({ ...prev, [questionId]: true }));
};
const handleCheckboxChange = (questionId, option, checked) => {
setAnswers((prev) => {
const current = prev[questionId] || [];
if (checked) {
return { ...prev, [questionId]: [...current, option] };
} else {
return {
...prev,
[questionId]: current.filter((item) => item !== option),
};
}
});
};
const handleSubmit = (e) => {
e.preventDefault();
// Mark all visible questions as touched
const allTouched = {};
visibleQuestions.forEach((q) => {
allTouched[q.id] = true;
});
setTouched(allTouched);
if (!isFormValid) {
alert('Vui lòng hoàn thành các câu hỏi bắt buộc!');
return;
}
// ── Success
console.log('Survey submitted:', answers);
setSubmitted(true);
// Clear draft sau khi submit thành công
localStorage.removeItem('survey-draft');
setAnswers({});
setTouched({});
};
const handleExport = () => {
const dataStr = JSON.stringify(answers, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'survey-answers.json';
link.click();
URL.revokeObjectURL(url);
};
// ── Render
if (submitted) {
return (
<div
style={{
textAlign: 'center',
padding: '40px',
maxWidth: 700,
margin: '0 auto',
}}
>
<h2 style={{ color: '#28a745' }}>
Cảm ơn bạn đã hoàn thành khảo sát! 🎉
</h2>
<p>Bạn có thể xem lại kết quả trong console.</p>
<button
onClick={() => {
setSubmitted(false);
setAnswers({});
}}
style={{
marginTop: 20,
padding: '12px 24px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: 16,
}}
>
Làm lại khảo sát
</button>
</div>
);
}
return (
<div style={{ maxWidth: 720, margin: '40px auto', padding: '0 20px' }}>
<h1>Khảo sát về React</h1>
{/* Progress bar */}
<div style={{ margin: '20px 0' }}>
<div
style={{
height: 12,
background: '#e0e0e0',
borderRadius: 6,
overflow: 'hidden',
}}
>
<div
style={{
width: `${progress}%`,
height: '100%',
background: progress === 100 ? '#28a745' : '#007bff',
transition: 'width 0.4s ease',
}}
/>
</div>
<p style={{ textAlign: 'center', marginTop: 8, color: '#555' }}>
Đã hoàn thành {answeredCount}/{visibleQuestions.length} câu hỏi •{' '}
{progress}%
</p>
</div>
<form onSubmit={handleSubmit}>
{visibleQuestions.map((question) => {
const value = answers[question.id];
const error = touched[question.id]
? validateAnswer(question, value)
: '';
const showError = !!error;
return (
<div
key={question.id}
style={{
marginBottom: 32,
padding: 20,
background: '#f8f9fa',
borderRadius: 8,
border: showError ? '2px solid #dc3545' : '1px solid #dee2e6',
}}
>
<label
style={{
fontWeight: 'bold',
display: 'block',
marginBottom: 12,
}}
>
{question.question}
{question.required && (
<span style={{ color: '#dc3545' }}> *</span>
)}
</label>
{/* ── Text / Textarea ── */}
{(question.type === 'text' || question.type === 'textarea') && (
<textarea
value={value || ''}
onChange={(e) => handleChange(question.id, e.target.value)}
onBlur={() => handleBlur(question.id)}
rows={question.type === 'textarea' ? 5 : 1}
style={{
width: '100%',
padding: 10,
border: showError
? '2px solid #dc3545'
: '1px solid #ced4da',
borderRadius: 4,
}}
/>
)}
{/* ── Number ── */}
{question.type === 'number' && (
<input
type='number'
min={question.min}
max={question.max}
value={value ?? ''}
onChange={(e) => handleChange(question.id, e.target.value)}
onBlur={() => handleBlur(question.id)}
style={{
width: '100%',
padding: 10,
border: showError
? '2px solid #dc3545'
: '1px solid #ced4da',
borderRadius: 4,
}}
/>
)}
{/* ── Select ── */}
{question.type === 'select' && (
<select
value={value || ''}
onChange={(e) => handleChange(question.id, e.target.value)}
onBlur={() => handleBlur(question.id)}
style={{
width: '100%',
padding: 10,
border: showError
? '2px solid #dc3545'
: '1px solid #ced4da',
borderRadius: 4,
}}
>
<option value=''>Chọn một lựa chọn...</option>
{question.options.map((opt) => (
<option
key={opt}
value={opt}
>
{opt}
</option>
))}
</select>
)}
{/* ── Radio ── */}
{question.type === 'radio' && (
<div
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
>
{question.options.map((opt) => (
<label
key={opt}
style={{ cursor: 'pointer' }}
>
<input
type='radio'
name={question.id}
value={opt}
checked={value === opt}
onChange={() => handleChange(question.id, opt)}
onBlur={() => handleBlur(question.id)}
/>{' '}
{opt}
</label>
))}
</div>
)}
{/* ── Checkbox ── */}
{question.type === 'checkbox' && (
<div
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
>
{question.options.map((opt) => (
<label
key={opt}
style={{ cursor: 'pointer' }}
>
<input
type='checkbox'
checked={(value || []).includes(opt)}
onChange={(e) =>
handleCheckboxChange(
question.id,
opt,
e.target.checked,
)
}
onBlur={() => handleBlur(question.id)}
/>{' '}
{opt}
</label>
))}
</div>
)}
{/* Error message */}
{showError && (
<p
style={{ color: '#dc3545', marginTop: 8, fontSize: '0.95em' }}
>
{error}
</p>
)}
</div>
);
})}
<div style={{ marginTop: 40, textAlign: 'center' }}>
<button
type='submit'
disabled={!isFormValid}
style={{
padding: '14px 40px',
fontSize: 18,
background: isFormValid ? '#28a745' : '#6c757d',
color: 'white',
border: 'none',
borderRadius: 6,
cursor: isFormValid ? 'pointer' : 'not-allowed',
marginRight: 16,
}}
>
Gửi khảo sát
</button>
<button
type='button'
onClick={handleExport}
style={{
padding: '14px 30px',
fontSize: 16,
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
}}
>
Export JSON
</button>
</div>
</form>
{/* Debug */}
<details style={{ marginTop: 60 }}>
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
Debug: Current Answers
</summary>
<pre
style={{
background: '#f1f3f5',
padding: 16,
borderRadius: 6,
overflow: 'auto',
maxHeight: 400,
}}
>
{JSON.stringify(answers, null, 2)}
</pre>
</details>
</div>
);
}
export default DynamicSurveyForm;### Một số điểm nổi bật trong giải pháp:
- **Config-driven** → chỉ cần sửa `SURVEY_CONFIG` là thêm được câu hỏi mới
- **Conditional logic** hoạt động tốt với `showIf`
- **Checkbox** xử lý dưới dạng mảng
- **Validation** chi tiết theo từng loại input
- **Progress bar** dựa trên câu hỏi **hiển thị** và **đã trả lời**
- **localStorage draft** tự động lưu & khôi phục
- **Export JSON** tiện lợi
- **Touched pattern** → chỉ hiện lỗi khi người dùng tương tác
- UX thân thiện, dễ debug📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh Trade-offs
| Approach | Pros ✅ | Cons ❌ | When to Use 🎯 |
|---|---|---|---|
| Controlled Components | • React controls value • Realtime validation • Dynamic UI easy • Single source of truth | • More boilerplate • onChange cho mọi input • Re-render on keystroke | • 95% of cases • Forms cần validation • Dynamic fields • Default choice |
| Uncontrolled Components (useRef) | • Less code • Giống HTML form • Ít re-renders | • Không biết value trước submit • Không validate realtime • Hard to sync với UI | • File uploads • Integration với non-React libs • Rare edge cases |
Multiple States[a, setA], [b, setB] | • Simple cho 2-3 fields • Clear separation | • Many setters • Hard to group/reset • Lots of onChange handlers | • Very simple forms (1-3 fields) • Unrelated fields |
Single Object State{a, b, c} | • Scalable • 1 generic handler • Easy reset • API-ready | • Need immutable updates • Computed property syntax | • Recommended for forms • 4+ fields • API submission |
| Validation: onChange | • Instant feedback | • Annoying (error while typing) | • ❌ Not recommended alone |
| Validation: onBlur | • Less annoying • Show error when done | • Delayed feedback | • ✅ Recommended primary |
| Validation: onSubmit | • Final check • Catch all errors | • Late feedback | • ✅ Always include |
| Touched State | • Only show errors for touched fields • Better UX | • Extra state to manage | • ✅ Production forms |
Decision Tree
Q1: Có bao nhiêu field trong form?
├─ 1-2 field → State riêng lẻ OK
└─ 3+ field → Dùng state dạng object
Q2: Có cần validate realtime không?
├─ CÓ → Controlled components (luôn luôn)
└─ KHÔNG → Vẫn dùng controlled (uncontrolled chỉ nên dùng cho input file)
Q3: Khi nào hiển thị lỗi validate?
├─ onBlur → Chính (hiện sau khi người dùng rời field)
├─ onSubmit → Luôn luôn (kiểm tra cuối)
└─ onChange → Hạn chế (chỉ xoá lỗi, không tạo lỗi mới)
Q4: Tổ chức state object như thế nào?
├─ Object phẳng (đa số trường hợp)
└─ Object lồng nhau (khi có nhóm logic, ví dụ: địa chỉ thanh toán vs giao hàng)
Q5: Có cần track field đã chạm (touched) không?
├─ Form đơn giản → Không bắt buộc
└─ Form production → Có (UX tốt hơn)
Q6: Chiến lược timing validate?
└─ Pattern tốt nhất:
• onBlur: Validate + đánh dấu touched
• onChange: Xoá lỗi (không tạo lỗi mới)
• onSubmit: Validate tất cả + đánh dấu tất cả touched🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Input Không Update ⭐
// 🐛 BUG: User gõ nhưng input không update
function BuggyForm() {
const [email, setEmail] = useState('');
return (
<form>
<input
type='email'
value={email}
// BUG: Missing onChange!
/>
</form>
);
}
/**
* 🔍 DEBUG QUESTIONS:
* 1. Điều gì xảy ra khi user gõ?
* 2. Tại sao input không update?
* 3. Fix như thế nào?
*/💡 Solution
Vấn đề:
- Input có
value={email}→ controlled - Nhưng KHÔNG có
onChange→ React không update state - Input bị "frozen" ở giá trị initial
Tại sao:
User types → onChange event fires → React ignores (no handler)
↓
State không update
↓
value vẫn = ''
↓
Input không đổiFix:
<input
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)} // ✅ Add handler
/>Lesson: Controlled input = PHẢI có value + onChange
Bug 2: Generic Handler Không Work ⭐⭐
// 🐛 BUG: All inputs update cùng lúc
function BuggyMultiForm() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
});
const handleChange = (e) => {
setFormData({
[e.target.name]: e.target.value, // BUG: Không spread prev
});
};
return (
<form>
<input
name='firstName'
value={formData.firstName}
onChange={handleChange}
/>
<input
name='lastName'
value={formData.lastName}
onChange={handleChange}
/>
</form>
);
}
/**
* 🔍 DEBUG QUESTIONS:
* 1. Gõ "John" vào firstName. formData sẽ là gì?
* 2. Sau đó gõ "Doe" vào lastName. formData sẽ là gì?
* 3. firstName có còn "John" không? Tại sao?
*/💡 Solution
Vấn đề:
// ❌ BAD: Overwrite entire object
setFormData({
[e.target.name]: e.target.value,
});
// After typing "John" in firstName:
formData = { firstName: 'John' }; // ❌ lastName bị mất!
// After typing "Doe" in lastName:
formData = { lastName: 'Doe' }; // ❌ firstName bị mất!Fix:
// ✅ GOOD: Spread prev, then override
const handleChange = (e) => {
setFormData((prev) => ({
...prev, // Keep old values
[e.target.name]: e.target.value,
}));
};Lesson: Immutable updates! Spread ...prev trước khi override.
Bug 3: Passwords Don't Match Validation ⭐⭐⭐
// 🐛 BUG: "Passwords don't match" không update khi password changes
function BuggyPasswordForm() {
const [formData, setFormData] = useState({
password: '',
confirmPassword: '',
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleBlur = (e) => {
const { name, value } = e.target;
if (name === 'confirmPassword') {
if (value !== formData.password) {
setErrors((prev) => ({
...prev,
confirmPassword: 'Passwords do not match',
}));
}
}
};
return (
<form>
<input
type='password'
name='password'
value={formData.password}
onChange={handleChange}
/>
<input
type='password'
name='confirmPassword'
value={formData.confirmPassword}
onChange={handleChange}
onBlur={handleBlur}
/>
{errors.confirmPassword && (
<p style={{ color: 'red' }}>{errors.confirmPassword}</p>
)}
</form>
);
}
/**
* 🔍 DEBUG SCENARIO:
* 1. User gõ "password123" vào password field
* 2. User gõ "password123" vào confirmPassword → onBlur → No error ✅
* 3. User quay lại password field, đổi thành "newpass456"
* 4. 🐛 ERROR vẫn không hiện! Tại sao?
* 5. Làm thế nào để fix?
*/💡 Solution
Vấn đề:
- Khi user đổi
password, error KHÔNG được re-validate confirmPasswordonBlur chỉ chạy khi user blur confirmPassword field- Nếu user đổi password sau, confirmPassword không được check lại
Fix 1: Re-validate confirmPassword khi password changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// ✅ If changing password, re-check confirmPassword
if (name === 'password' && formData.confirmPassword) {
if (value !== formData.confirmPassword) {
setErrors((prev) => ({
...prev,
confirmPassword: 'Passwords do not match',
}));
} else {
// Clear error if now they match
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.confirmPassword;
return newErrors;
});
}
}
};Fix 2: Clear error onChange (better UX)
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// ✅ Clear error when user types (good UX)
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
// ✅ Cross-field validation
if (name === 'password' && formData.confirmPassword) {
validateConfirmPassword(value, formData.confirmPassword);
}
};Lesson: Cross-field validation cần update khi ANY related field changes!
✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
- [ ] Tôi hiểu controlled vs uncontrolled components
- [ ] Tôi biết khi nào dùng object state vs multiple states
- [ ] Tôi có thể implement generic onChange handler với
e.target.name - [ ] Tôi hiểu validation timing (onChange vs onBlur vs onSubmit)
- [ ] Tôi biết cách implement "touched" state pattern
- [ ] Tôi có thể validate cross-field dependencies (password match)
- [ ] Tôi hiểu tại sao cần
e.preventDefault()trong onSubmit - [ ] Tôi có thể structure validators thành reusable functions
- [ ] Tôi biết cách clear form sau submit
- [ ] Tôi hiểu trade-offs của mỗi validation approach
Code Review Checklist
Khi review code form:
Cấu trúc State:
- [ ] Các field liên quan được nhóm trong object (không quá nhiều state tách rời)
- [ ] Update bất biến (spread
...prev) - [ ] Không lưu derived state (tính
isValid, không store)
Controlled Components:
- [ ] Mọi input đều có prop
value - [ ] Mọi input đều có handler
onChange - [ ] Thuộc tính
namecủa input khớp với key trong state
Validation:
- [ ] Chỉ hiển thị lỗi cho field đã touched (UX tốt)
- [ ] onBlur validate field
- [ ] onChange xoá lỗi (không hiển thị lỗi khi đang gõ)
- [ ] onSubmit validate tất cả
- [ ] Có xử lý validate giữa các field
Submit Handler:
- [ ] Có
e.preventDefault() - [ ] Validate lần cuối trước khi submit
- [ ] Xử lý khi thành công (clear form, hiển thị message)
- [ ] Xử lý khi có lỗi
UX:
- [ ] Disable nút submit khi form không hợp lệ
- [ ] Thông báo lỗi rõ ràng, dễ hiểu
- [ ] Bộ đếm ký tự khi cần
- [ ] Loading state (sẽ học với useEffect)
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Exercise: Fix Broken Forms
Cho 3 broken forms. Tìm và fix bugs:
- Form 1: Input không update khi gõ
💡 Xem code bị lỗi + giải thích + code đã sửa
Code bị lỗi:
function Form1() {
const [email, setEmail] = useState('');
return (
<form>
<input
type='email'
value={email}
/>
<p>Bạn gõ: {email}</p>
</form>
);
}Nguyên nhân:
Controlled input nhưng thiếu onChange → React không cập nhật state → input bị "đóng băng".
Code đã sửa:
function Form1Fixed() {
const [email, setEmail] = useState('');
return (
<form>
<input
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder='example@gmail.com'
/>
<p>Bạn đang gõ: {email || '(chưa nhập)'}</p>
</form>
);
}Bài học: Controlled input phải có cả value + onChange.
- Form 2: Submit không work (page reload)
💡 Xem code bị lỗi + giải thích + code đã sửa
Code bị lỗi:
function Form2() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = () => {
console.log({ username, password });
alert('Đăng nhập!');
};
return (
<form onSubmit={handleSubmit}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type='submit'>Đăng nhập</button>
</form>
);
}Nguyên nhân:
Không gọi e.preventDefault() → browser thực hiện submit mặc định → reload trang.
Code đã sửa:
function Form2Fixed() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault(); // ← Fix quan trọng nhất
console.log('Đăng nhập:', { username, password });
setUsername('');
setPassword('');
alert('Đăng nhập thành công (không reload)!');
};
return (
<form onSubmit={handleSubmit}>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type='submit'>Đăng nhập</button>
</form>
);
}Bài học: Trong React, luôn gọi e.preventDefault() trong hàm onSubmit.
- Form 3: Email validation sai (accept invalid emails)
💡 Xem code bị lỗi + giải thích + code đã sửa
Code bị lỗi:
function Form3() {
const [email, setEmail] = useState('');
const isValid = email.includes('@');
const handleSubmit = (e) => {
e.preventDefault();
if (!isValid) {
alert('Email sai!');
return;
}
alert('Email ok: ' + email);
};
return (
<form onSubmit={handleSubmit}>
<input
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button type='submit'>Gửi</button>
</form>
);
}Nguyên nhân:
Chỉ kiểm tra có @ → chấp nhận các email sai như:abc@@gmail.coma@bhello@world
Code đã sửa (phiên bản tốt hơn):
function Form3Fixed() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const validate = (value) => {
if (!value.trim()) return 'Vui lòng nhập email';
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return regex.test(value) ? '' : 'Email không đúng định dạng';
};
const handleChange = (e) => {
const val = e.target.value;
setEmail(val);
setError(validate(val));
};
const handleSubmit = (e) => {
e.preventDefault();
const err = validate(email);
if (err) {
setError(err);
alert('Vui lòng sửa email!');
return;
}
alert('Email hợp lệ: ' + email);
setEmail('');
setError('');
};
return (
<form onSubmit={handleSubmit}>
<input
type='email'
value={email}
onChange={handleChange}
onBlur={() => setError(validate(email))}
/>
{error && <p style={{ color: 'red', fontSize: '0.9em' }}>{error}</p>}
<button type='submit'>Gửi</button>
</form>
);
}Bài học:
Validation email cần regex tốt hơn + nên kiểm tra cả trường hợp rỗng.
Tóm tắt 3 lỗi phổ biến cần nhớ:
- Thiếu
onChange→ input không gõ được - Quên
e.preventDefault()→ trang reload khi submit - Validation quá yếu → cho qua email sai
Nâng cao (60 phút)
Exercise: Job Application Form
Tạo form apply job với:
- Personal: name, email, phone
- Experience: yearsOfExperience (number), currentRole, resume (text describing experience)
- Motivation: why (textarea min 100 chars)
- Validation realtime + onSubmit
- Touched state pattern
- Character counters
- Progress indicator (X/Y fields completed)
Requirements:
- ✅ Object state
- ✅ Generic handlers
- ✅ Proper validation timing
- ✅ Production-ready error handling
💡 Xem đáp án
import { useState } from 'react';
function JobApplicationForm() {
// ─── State chính ───────────────────────────────────────────────
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
yearsOfExperience: '',
currentRole: '',
resume: '', // mô tả kinh nghiệm (text)
why: '', // lý do ứng tuyển (textarea)
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
// ─── Validation rules ─────────────────────────────────────────
const validateField = (name, value) => {
switch (name) {
case 'name':
if (!value.trim()) return 'Họ và tên là bắt buộc';
if (value.trim().length < 2) return 'Tên quá ngắn';
return '';
case 'email':
if (!value.trim()) return 'Email là bắt buộc';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
return 'Email không đúng định dạng';
return '';
case 'phone':
if (!value.trim()) return 'Số điện thoại là bắt buộc';
if (!/^(0|\+84)[0-9]{9,10}$/.test(value.replace(/\s/g, '')))
return 'Số điện thoại không hợp lệ (10-11 số, bắt đầu bằng 0 hoặc +84)';
return '';
case 'yearsOfExperience':
if (value === '') return 'Vui lòng nhập số năm kinh nghiệm';
const num = Number(value);
if (isNaN(num) || num < 0) return 'Vui lòng nhập số ≥ 0';
return '';
case 'currentRole':
if (!value.trim()) return 'Vị trí hiện tại là bắt buộc';
return '';
case 'resume':
if (!value.trim()) return 'Mô tả kinh nghiệm là bắt buộc';
if (value.trim().length < 50)
return `Mô tả quá ngắn (${value.trim().length}/50 ký tự tối thiểu)`;
return '';
case 'why':
if (!value.trim()) return 'Lý do ứng tuyển là bắt buộc';
if (value.trim().length < 100)
return `Nội dung quá ngắn (${value.trim().length}/100 ký tự tối thiểu)`;
return '';
default:
return '';
}
};
// ─── Generic change handler ───────────────────────────────────
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Xóa lỗi ngay khi người dùng gõ (UX tốt)
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
// ─── Blur → validate field ────────────────────────────────────
const handleBlur = (e) => {
const { name, value } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
const error = validateField(name, value);
if (error) {
setErrors((prev) => ({ ...prev, [name]: error }));
} else {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
// ─── Validation toàn bộ form (dùng cho nút submit & progress) ──
const getAllErrors = () => {
const allErrors = {};
Object.keys(formData).forEach((field) => {
const err = validateField(field, formData[field]);
if (err) allErrors[field] = err;
});
return allErrors;
};
const allErrors = getAllErrors();
const isFormValid = Object.keys(allErrors).length === 0;
// ─── Progress: đếm field đã hoàn thành hợp lệ ─────────────────
const requiredFields = [
'name',
'email',
'phone',
'yearsOfExperience',
'currentRole',
'resume',
'why',
];
const completedCount = requiredFields.filter((field) => {
const val = formData[field];
if (field === 'why') return val.trim().length >= 100;
if (field === 'resume') return val.trim().length >= 50;
if (field === 'yearsOfExperience')
return val !== '' && !isNaN(Number(val)) && Number(val) >= 0;
return val.trim() !== '';
}).length;
const progress = Math.round((completedCount / requiredFields.length) * 100);
// ─── Submit ───────────────────────────────────────────────────
const handleSubmit = (e) => {
e.preventDefault();
// Mark tất cả field là touched để hiện hết lỗi
const allTouched = {};
requiredFields.forEach((f) => (allTouched[f] = true));
setTouched(allTouched);
const validationErrors = getAllErrors();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
alert('Vui lòng kiểm tra lại các trường có lỗi.');
return;
}
// ── Thành công ──────────────────────────────────────────────
console.log('Đơn ứng tuyển:', formData);
alert(
'Đơn ứng tuyển đã được gửi thành công!\n\n(Có thể xem console để kiểm tra dữ liệu)',
);
// Reset form
setFormData({
name: '',
email: '',
phone: '',
yearsOfExperience: '',
currentRole: '',
resume: '',
why: '',
});
setErrors({});
setTouched({});
};
// ─── Render ───────────────────────────────────────────────────
return (
<div style={{ maxWidth: 680, margin: '40px auto', padding: '0 20px' }}>
<h1>Ứng tuyển vị trí Front-end Developer</h1>
{/* Progress bar */}
<div style={{ margin: '24px 0' }}>
<div
style={{
height: 10,
background: '#e9ecef',
borderRadius: 5,
overflow: 'hidden',
}}
>
<div
style={{
width: `${progress}%`,
height: '100%',
background: progress === 100 ? '#28a745' : '#0d6efd',
transition: 'width 0.4s ease',
}}
/>
</div>
<p style={{ textAlign: 'center', marginTop: 8, color: '#495057' }}>
Đã hoàn thành {completedCount}/{requiredFields.length} trường •{' '}
{progress}%
</p>
</div>
<form
onSubmit={handleSubmit}
noValidate
>
<fieldset style={{ marginBottom: 32 }}>
<legend>Thông tin cá nhân</legend>
<div style={{ marginBottom: 20 }}>
<label>Họ và tên *</label>
<input
name='name'
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
style={{
borderColor:
touched.name && errors.name ? '#dc3545' : '#ced4da',
}}
/>
{touched.name && errors.name && (
<div className='error'>{errors.name}</div>
)}
</div>
<div style={{ marginBottom: 20 }}>
<label>Email *</label>
<input
type='email'
name='email'
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
style={{
borderColor:
touched.email && errors.email ? '#dc3545' : '#ced4da',
}}
/>
{touched.email && errors.email && (
<div className='error'>{errors.email}</div>
)}
</div>
<div style={{ marginBottom: 20 }}>
<label>Số điện thoại *</label>
<input
type='tel'
name='phone'
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
style={{
borderColor:
touched.phone && errors.phone ? '#dc3545' : '#ced4da',
}}
/>
{touched.phone && errors.phone && (
<div className='error'>{errors.phone}</div>
)}
</div>
</fieldset>
<fieldset style={{ marginBottom: 32 }}>
<legend>Kinh nghiệm làm việc</legend>
<div style={{ marginBottom: 20 }}>
<label>Số năm kinh nghiệm *</label>
<input
type='number'
min='0'
name='yearsOfExperience'
value={formData.yearsOfExperience}
onChange={handleChange}
onBlur={handleBlur}
style={{
borderColor:
touched.yearsOfExperience && errors.yearsOfExperience
? '#dc3545'
: '#ced4da',
}}
/>
{touched.yearsOfExperience && errors.yearsOfExperience && (
<div className='error'>{errors.yearsOfExperience}</div>
)}
</div>
<div style={{ marginBottom: 20 }}>
<label>Vị trí / vai trò hiện tại *</label>
<input
name='currentRole'
value={formData.currentRole}
onChange={handleChange}
onBlur={handleBlur}
placeholder='VD: Senior React Developer'
style={{
borderColor:
touched.currentRole && errors.currentRole
? '#dc3545'
: '#ced4da',
}}
/>
{touched.currentRole && errors.currentRole && (
<div className='error'>{errors.currentRole}</div>
)}
</div>
<div style={{ marginBottom: 20 }}>
<label>Mô tả kinh nghiệm làm việc *</label>
<textarea
name='resume'
value={formData.resume}
onChange={handleChange}
onBlur={handleBlur}
rows={5}
placeholder='Mô tả ngắn gọn về dự án, công nghệ đã sử dụng, thành tựu nổi bật...'
style={{
borderColor:
touched.resume && errors.resume ? '#dc3545' : '#ced4da',
}}
/>
<div className='counter'>
{formData.resume.trim().length} / tối thiểu 50 ký tự
</div>
{touched.resume && errors.resume && (
<div className='error'>{errors.resume}</div>
)}
</div>
</fieldset>
<fieldset style={{ marginBottom: 32 }}>
<legend>Lý do ứng tuyển</legend>
<div style={{ marginBottom: 20 }}>
<label>Tại sao bạn muốn làm việc tại công ty chúng tôi? *</label>
<textarea
name='why'
value={formData.why}
onChange={handleChange}
onBlur={handleBlur}
rows={6}
placeholder='Hãy chia sẻ động lực, điều bạn mong muốn học hỏi, đóng góp...'
style={{
borderColor: touched.why && errors.why ? '#dc3545' : '#ced4da',
}}
/>
<div className='counter'>
{formData.why.trim().length} / tối thiểu 100 ký tự
</div>
{touched.why && errors.why && (
<div className='error'>{errors.why}</div>
)}
</div>
</fieldset>
<button
type='submit'
disabled={!isFormValid}
className={`submit-btn ${isFormValid ? 'valid' : 'invalid'}`}
>
Gửi đơn ứng tuyển
</button>
{!isFormValid && (
<p style={{ color: '#dc3545', marginTop: 12, textAlign: 'center' }}>
Vui lòng hoàn thành tất cả các trường bắt buộc và sửa lỗi (nếu có).
</p>
)}
</form>
{/* ── CSS inline cho gọn ────────────────────────────────────── */}
<style jsx>{`
label {
display: block;
margin-bottom: 6px;
font-weight: 600;
}
input,
textarea {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 16px;
box-sizing: border-box;
}
input:focus,
textarea:focus {
outline: none;
border-color: #0d6efd;
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15);
}
.error {
color: #dc3545;
font-size: 0.9em;
margin-top: 6px;
}
.counter {
font-size: 0.85em;
color: #6c757d;
margin-top: 4px;
text-align: right;
}
.submit-btn {
width: 100%;
padding: 14px;
font-size: 17px;
font-weight: 600;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.submit-btn.valid {
background: #28a745;
}
.submit-btn.invalid {
background: #6c757d;
cursor: not-allowed;
}
fieldset {
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 32px;
}
legend {
font-weight: bold;
padding: 0 12px;
color: #0d6efd;
}
`}</style>
</div>
);
}
export default JobApplicationForm;Các điểm chính đã đáp ứng:
- Object state duy nhất cho toàn bộ form
- Generic handler
handleChange+handleBlur - Validation timing hợp lý:
- onChange → xóa lỗi (UX mượt)
- onBlur → validate & hiện lỗi
- onSubmit → validate toàn bộ + mark all touched
- Touched pattern → chỉ hiện lỗi sau khi người dùng tương tác
- Character counters cho
resumevàwhy - Progress indicator dựa trên số field hoàn thành hợp lệ
- Error handling production-ready (inline style + class cho nút submit)
📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - Forms
- https://react.dev/reference/react-dom/components/input
- Đọc sections về controlled components
React Docs - Managing State
- https://react.dev/learn/managing-state
- Especially "Sharing State Between Components"
Đọc thêm
Form Validation Best Practices
- https://www.smashingmagazine.com/2022/09/inline-validation-web-forms-ux/
- UX perspective on validation timing
HTML Form Validation
- https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation
- Native HTML validation (để biết nên override gì)
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (đã học)
- Ngày 12: useState patterns - Immutability, functional updates, object state
- Ngày 11: useState basics
- Ngày 5: Event handling -
e.target,e.preventDefault()
Hướng tới (sẽ học)
- Ngày 14: Lifting State Up - Share form state giữa components
- Ngày 17-21: useEffect - Persist forms to localStorage, debounce validation
- Ngày 36-38: Forms Deep Dive - Formik, React Hook Form, advanced patterns
💡 SENIOR INSIGHTS
Cân Nhắc Production
Performance:
// ⚠️ Re-render on every keystroke
function ExpensiveForm() {
const [formData, setFormData] = useState({...});
// ❌ Expensive computation runs every render
const validationResult = expensiveValidation(formData);
// ✅ Will learn useMemo in Ngày 23 to optimize
// For now: Keep validators fast & simple
}Accessibility:
// ✅ Production-ready accessible form
<form>
<label htmlFor='email'>Email</label>
<input
id='email'
name='email'
type='email'
value={formData.email}
onChange={handleChange}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p
id='email-error'
role='alert'
>
{errors.email}
</p>
)}
</form>Security:
// ⚠️ Never trust client-side validation alone!
const handleSubmit = async (e) => {
e.preventDefault();
// ✅ Client validation (UX)
if (!isValid) return;
try {
// ✅ Server MUST validate again (security)
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData),
});
// Handle server validation errors
if (!response.ok) {
const serverErrors = await response.json();
setErrors(serverErrors);
}
} catch (err) {
// Handle network errors
}
};Câu Hỏi Phỏng Vấn
Junior:
Q: "Controlled component là gì?"
A: Component mà React controls input value through state. Input có value={state} và onChange={setState}. React là single source of truth.
Mid:
Q: "Làm thế nào handle form với nhiều inputs efficiently?"
A: Dùng object state + generic handler với e.target.name. Mỗi input có name attribute match state key. Handler dùng computed property [name]: value với spread operator.
Senior:
Q: "Thiết kế chiến lược validate cho form phức tạp."
A: Cách tiếp cận nhiều lớp:
- onBlur: Validate field, đánh dấu touched (hiển thị lỗi)
- onChange: Xoá lỗi (UX tốt khi đang gõ)
- onSubmit: Validate tất cả, đánh dấu tất cả touched
- Cross-field: Validate lại các field phụ thuộc khi field liên quan thay đổi
- Server: Luôn validate phía server (bảo mật)
- Cân nhắc debounce cho validate async (email, username)
War Stories
Story 1: The Missing e.preventDefault() Bug
Junior dev tạo form, mọi thứ work local nhưng production bị bug lạ: form submit xong page bị blank. Root cause: Quên
e.preventDefault()→ browser submit form → page reload → blank page.Lesson: LUÔN preventDefault trong onSubmit React forms!
Story 2: The Stale Password Validation
Người dùng than phiền: “Form báo mật khẩu không khớp dù tôi nhập y hệt nhau!” Debug mãi mới phát hiện nguyên nhân: ô password có bật autocomplete, nên khi mật khẩu thay đổi, validation của ô confirm password không được chạy lại.
Cách fix: mỗi khi password thay đổi thì re-validate confirm password. Bài học rút ra: Validation giữa nhiều field (cross-field validation) phải xử lý tất cả các thay đổi liên quan, không chỉ mỗi field đang nhập.
Story 3: The Performance Killer Form
Form có 50 field nên mỗi lần gõ phím đều bị lag ~100ms. Profiling mới thấy nguyên nhân: mỗi onChange lại chạy regex validation cho toàn bộ 50 field.
Cách fix:
– Khi gõ (onChange) chỉ validate field đang thay đổi
– Dời việc validate toàn bộ sang lúc onBlur
Bài học: Thời điểm chạy validation ảnh hưởng rất lớn đến performance.
🎯 PREVIEW NGÀY MAI
Ngày 14: Lifting State Up
Hôm nay đã master forms với local state. Ngày mai sẽ học:
- Share state giữa sibling components
- Lift state lên parent
- Inverse data flow (child → parent communication)
- When to lift vs when to keep local
- Props drilling và cách giảm thiểu
Preview challenge: Tạo shopping cart nơi ProductList và CartSummary cần share state!
Hôm nay: Forms mastery ✅
Ngày mai: State communication 🎯
🎊 CHÚC MỪNG! Bạn đã hoàn thành Ngày 13!
Hôm nay bạn đã master:
- ✅ Controlled vs Uncontrolled components
- ✅ Multiple inputs với object state
- ✅ Validation strategies (onBlur, onChange, onSubmit)
- ✅ Touched state pattern
- ✅ Cross-field validation
- ✅ Production-ready form patterns
Forms là nền tảng của hầu hết web apps. Master forms = master React!
Pro Tip: Mọi form pattern học hôm nay sẽ scale đến bất kỳ form nào - từ login đơn giản đến checkout phức tạp với 100+ fields!
💪 Keep practicing! Tomorrow: State communication!