📅 NGÀY 9: FORMS & INPUT HANDLING - Nền Tảng Controlled Components
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Hiểu được cách form elements hoạt động trong React khác với HTML thuần
- [ ] Nắm vững khái niệm Controlled Components (lý thuyết) và tại sao React khuyến nghị pattern này
- [ ] Xử lý được form events và truy xuất giá trị input thông qua event.target
- [ ] Phân biệt được uncontrolled vs controlled inputs (chuẩn bị cho useState ở Ngày 11)
- [ ] Implement được form validation cơ bản với event handlers
🤔 Kiểm tra đầu vào (5 phút)
Trả lời 3 câu hỏi này để kích hoạt kiến thức nền:
- Event Handling (Ngày 5): Làm thế nào để lấy giá trị từ
event.target.value? - Conditional Rendering (Ngày 5): Làm sao hiển thị error message khi điều kiện nào đó xảy ra?
- Props (Ngày 4): Callback function được truyền qua props có thể nhận parameters không?
💡 Xem đáp án
- Trong event handler:
const value = event.target.value - Dùng
&&hoặc ternary:{error && <span>{error}</span>} - Có! Ví dụ:
onSubmit={(formData) => console.log(formData)}
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Hãy tưởng tượng bạn đang xây dựng form đăng ký như sau:
// ❌ HTML thuần - Cách cũ
<form>
<input
type='text'
name='email'
/>
<input
type='password'
name='password'
/>
<button type='submit'>Đăng ký</button>
</form>Những vấn đề khi dùng HTML form thuần trong React:
- Không kiểm soát được giá trị realtime → Không validate khi user đang gõ
- DOM quản lý state → React không biết giá trị hiện tại
- Khó implement UX tốt → Không disable button khi form invalid
- Không thể transform input → Ví dụ: format số điện thoại khi gõ
// ⚠️ Vấn đề cụ thể
function SignupForm() {
// Làm sao biết email có hợp lệ TRƯỚC KHI user submit?
// Làm sao disable button khi password < 8 ký tự?
// Làm sao hiển thị strength meter cho password?
return (
<form
onSubmit={(e) => {
e.preventDefault();
// ❌ Giá trị ở đâu? Phải query DOM!
const formData = new FormData(e.target);
console.log(formData.get('email')); // Cồng kềnh!
}}
>
<input
type='email'
name='email'
/>
<input
type='password'
name='password'
/>
<button type='submit'>Đăng ký</button>
</form>
);
}1.2 Giải Pháp - Controlled Components
React giải quyết bằng Controlled Components - React quản lý giá trị form:
// ✅ React Controlled Pattern (Lý thuyết)
function SignupForm() {
// ⚠️ CHÚ Ý: Đây là GIẢI THÍCH PATTERN
// useState sẽ học ở Ngày 11!
// Concept: React lưu giá trị thay vì DOM
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
return (
<form>
{/* Value được React quản lý */}
<input
type='email'
value={email} // ← React controls this
onChange={(e) => setEmail(e.target.value)}
/>
{/* Giờ có thể validate realtime */}
{password.length > 0 && password.length < 8 && (
<p>Mật khẩu phải ít nhất 8 ký tự</p>
)}
<input
type='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{/* Disable button khi invalid */}
<button
type='submit'
disabled={!email || password.length < 8}
>
Đăng ký
</button>
</form>
);
}🔑 Key Insight: Trong Controlled Component:
- React là "single source of truth"
- Input value luôn phản ánh React's state
- Mọi thay đổi đều qua React (không có DOM mutations)
1.3 Mental Model
HTML FORM (Uncontrolled)
┌─────────────────────────────────────┐
│ DOM quản lý value │
│ │
│ User types → DOM updates │
│ │
│ Submit → Read from DOM │
└─────────────────────────────────────┘
VS
REACT CONTROLLED FORM
┌─────────────────────────────────────┐
│ React quản lý value │
│ │
│ User types → Event │
│ ↓ │
│ Update React state │
│ ↓ │
│ Re-render với value mới │
│ ↓ │
│ DOM reflects React state │
└─────────────────────────────────────┘🎯 Analogy: Controlled component giống như:
- Uncontrolled: Bạn cho người lạ mượn xe → Không biết họ đi đâu cho đến khi trả xe
- Controlled: Bạn làm tài xế → Biết chính xác đang ở đâu mọi lúc
1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Tôi cần useState để handle forms"
// ❌ SAI: Nghĩ phải có state mới handle được form
// Thực tế: Có thể handle events mà KHÔNG cần state!
function SearchBar() {
// KHÔNG cần state nếu chỉ cần giá trị khi submit
return (
<form
onSubmit={(e) => {
e.preventDefault();
const query = e.target.elements.search.value;
console.log('Search for:', query);
// Gọi API, navigate, etc.
}}
>
<input
type='text'
name='search' // ← Truy cập qua e.target.elements.search
placeholder='Tìm kiếm...'
/>
<button type='submit'>Tìm</button>
</form>
);
}✅ ĐÚNG: Forms có thể hoạt động mà không cần state nếu:
- Chỉ cần giá trị khi submit (không cần realtime validation)
- Không cần disable/enable elements dựa trên giá trị
- Không cần derived UI (ví dụ: character counter)
❌ Hiểu lầm 2: "onChange là event giống HTML"
// ❌ SAI: Nghĩ onChange trong React = onchange trong HTML
// HTML onchange: Chỉ fire khi blur
// React onChange: Fire sau mỗi keystroke!
function Demo() {
return (
<>
{/* HTML thuần: onChange chỉ chạy khi blur */}
<input
type='text'
onchange="console.log('HTML change')" // Chỉ khi blur!
/>
{/* React: onChange chạy mỗi keystroke */}
<input
type='text'
onChange={(e) => console.log('React change:', e.target.value)}
// Gõ "hello" → 5 lần console.log!
/>
</>
);
}✅ ĐÚNG: React's onChange thực chất là onInput của HTML!
❌ Hiểu lầm 3: "Controlled = Phải có value prop"
// ❌ SAI LẦM NGHIÊM TRỌNG: Controlled mà thiếu onChange
function BrokenInput() {
return (
<input
type='text'
value='Fixed value' // ← Controlled nhưng không có onChange
// ❌ Input sẽ bị read-only!
/>
);
}
// ⚠️ React warning:
// "You provided a `value` prop to a form field
// without an `onChange` handler. This will render
// a read-only field."✅ ĐÚNG: Controlled component CẦN CẢ HAI:
value={something}để React controlonChange={handler}để update value
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Uncontrolled Form - Submit Handler Pattern ⭐
🎯 Use Case: Form đơn giản chỉ cần giá trị khi submit
/**
* ✅ PATTERN: Uncontrolled với name attribute
*
* Khi nào dùng:
* - Form đơn giản (1-3 fields)
* - Không cần validation realtime
* - Không cần disabled state dựa trên input
*
* Ưu điểm:
* - Code ít hơn (không cần state)
* - Performance tốt (ít re-renders)
*
* Nhược điểm:
* - Không kiểm soát được giá trị
* - Khó implement UX nâng cao
*/
function ContactForm() {
// Hàm xử lý submit
const handleSubmit = (event) => {
event.preventDefault(); // ⚠️ BẮT BUỘC! Ngăn page reload
// Cách 1: Dùng FormData API (Modern)
const formData = new FormData(event.target);
const data = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
console.log('Form submitted:', data);
// Cách 2: Dùng event.target.elements
const { name, email, message } = event.target.elements;
console.log('Via elements:', {
name: name.value,
email: email.value,
message: message.value,
});
// Reset form sau khi submit
event.target.reset();
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor='name'>Họ tên:</label>
<input
type='text'
id='name'
name='name' // ← KEY: name attribute để truy cập
required // HTML validation
placeholder='Nguyễn Văn A'
/>
</div>
<div>
<label htmlFor='email'>Email:</label>
<input
type='email' // HTML tự validate email format
id='email'
name='email'
required
placeholder='example@email.com'
/>
</div>
<div>
<label htmlFor='message'>Tin nhắn:</label>
<textarea
id='message'
name='message'
rows={4}
required
placeholder='Nội dung tin nhắn...'
/>
</div>
<button type='submit'>Gửi</button>
</form>
);
}🔍 Key Learnings:
event.preventDefault()- Ngăn form submit mặc địnhnameattribute - Cách truy cập giá trị formFormDataAPI - Modern way to read form values- HTML validation -
required,type="email"tự động validate
Demo 2: Event Handling Patterns - Real-world Scenarios ⭐⭐
🎯 Use Case: Xử lý nhiều loại form elements và events
/**
* ✅ PATTERN: Comprehensive Event Handling
*
* Bao gồm:
* - Text inputs, checkboxes, radio buttons
* - Select dropdowns
* - Event delegation
* - Validation trước khi submit
*/
function RegistrationForm() {
// Xử lý submit với validation
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
// Extract data
const data = {
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
country: formData.get('country'),
terms: formData.get('terms'), // checkbox: "on" hoặc null
newsletter: formData.get('newsletter'), // "on" hoặc null
};
// Validation logic
const errors = [];
if (data.username.length < 3) {
errors.push('Username phải ít nhất 3 ký tự');
}
if (data.password.length < 8) {
errors.push('Mật khẩu phải ít nhất 8 ký tự');
}
if (!data.terms) {
errors.push('Bạn phải đồng ý với điều khoản');
}
// Hiển thị errors
if (errors.length > 0) {
alert('Lỗi:\n' + errors.join('\n'));
return;
}
// Submit success
console.log('Registration data:', {
...data,
terms: !!data.terms,
newsletter: !!data.newsletter,
});
};
// Xử lý change event cho preview
const handleUsernameChange = (event) => {
const value = event.target.value;
console.log('Username changed:', value);
// ⚠️ Lưu ý: Đây chỉ là log, không update UI
// Để update UI cần state (Ngày 11)
};
const handlePasswordChange = (event) => {
const value = event.target.value;
// Đếm độ mạnh password (chỉ log, chưa hiển thị)
let strength = 0;
if (value.length >= 8) strength++;
if (/[A-Z]/.test(value)) strength++;
if (/[0-9]/.test(value)) strength++;
if (/[^A-Za-z0-9]/.test(value)) strength++;
console.log('Password strength:', strength + '/4');
};
return (
<form onSubmit={handleSubmit}>
{/* Text Input */}
<div>
<label htmlFor='username'>Username:</label>
<input
type='text'
id='username'
name='username'
onChange={handleUsernameChange}
placeholder='username123'
minLength={3} // HTML validation
required
/>
</div>
{/* Email Input */}
<div>
<label htmlFor='email'>Email:</label>
<input
type='email'
id='email'
name='email'
placeholder='user@example.com'
required
/>
</div>
{/* Password Input */}
<div>
<label htmlFor='password'>Password:</label>
<input
type='password'
id='password'
name='password'
onChange={handlePasswordChange}
minLength={8}
required
/>
</div>
{/* Select Dropdown */}
<div>
<label htmlFor='country'>Quốc gia:</label>
<select
id='country'
name='country'
defaultValue='' // ← Giá trị mặc định
required
>
<option
value=''
disabled
>
-- Chọn quốc gia --
</option>
<option value='vn'>Việt Nam</option>
<option value='us'>United States</option>
<option value='jp'>Japan</option>
<option value='kr'>South Korea</option>
</select>
</div>
{/* Checkbox */}
<div>
<label>
<input
type='checkbox'
name='terms'
required // HTML yêu cầu check
/>{' '}
Tôi đồng ý với điều khoản sử dụng
</label>
</div>
{/* Checkbox (optional) */}
<div>
<label>
<input
type='checkbox'
name='newsletter'
defaultChecked // ← Checked mặc định
/>{' '}
Nhận bản tin qua email
</label>
</div>
<button type='submit'>Đăng ký</button>
</form>
);
}🔑 Key Patterns:
// ✅ Checkbox value
const isChecked = formData.get('checkbox'); // "on" hoặc null
const boolValue = !!formData.get('checkbox'); // true hoặc false
// ✅ Select default value
<select defaultValue="vn"> // Không dùng selected attribute
// ✅ Input events
onChange // Mỗi keystroke (React khác HTML!)
onBlur // Khi rời khỏi input
onFocus // Khi focus vào input
onInput // Giống onChange trong ReactDemo 3: Edge Cases & Validation ⭐⭐⭐
🎯 Use Case: Handle các trường hợp đặc biệt và validate phức tạp
/**
* ✅ ADVANCED: Edge Cases Handling
*
* Xử lý:
* - Whitespace trimming
* - Cross-field validation
* - Custom validation rules
* - Error display patterns
*/
function AdvancedForm() {
const handleSubmit = (event) => {
event.preventDefault();
// Helper: Lấy giá trị và trim whitespace
const getValue = (name) => {
const value = event.target.elements[name].value;
return value.trim(); // ← Xóa khoảng trắng đầu/cuối
};
// Extract và sanitize data
const data = {
fullName: getValue('fullName'),
email: getValue('email'),
phone: getValue('phone'),
password: getValue('password'),
confirmPassword: getValue('confirmPassword'),
age: event.target.elements.age.value,
};
// Validation errors array
const errors = [];
// 1. Required fields
if (!data.fullName) {
errors.push('Họ tên không được để trống');
}
if (!data.email) {
errors.push('Email không được để trống');
}
if (!data.password) {
errors.push('Mật khẩu không được để trống');
}
// 2. Format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (data.email && !emailRegex.test(data.email)) {
errors.push('Email không hợp lệ');
}
const phoneRegex = /^[0-9]{10,11}$/;
if (data.phone && !phoneRegex.test(data.phone)) {
errors.push('Số điện thoại phải là 10-11 chữ số');
}
// 3. Length validation
if (data.fullName && data.fullName.length < 3) {
errors.push('Họ tên phải ít nhất 3 ký tự');
}
if (data.password && data.password.length < 8) {
errors.push('Mật khẩu phải ít nhất 8 ký tự');
}
// 4. Cross-field validation
if (data.password !== data.confirmPassword) {
errors.push('Mật khẩu xác nhận không khớp');
}
// 5. Range validation
const ageNum = parseInt(data.age, 10);
if (isNaN(ageNum) || ageNum < 18 || ageNum > 100) {
errors.push('Tuổi phải từ 18 đến 100');
}
// 6. Password strength
const hasUpperCase = /[A-Z]/.test(data.password);
const hasLowerCase = /[a-z]/.test(data.password);
const hasNumber = /[0-9]/.test(data.password);
const hasSpecial = /[^A-Za-z0-9]/.test(data.password);
if (data.password && !(hasUpperCase && hasLowerCase && hasNumber)) {
errors.push('Mật khẩu phải có chữ hoa, chữ thường và số');
}
// Display errors
if (errors.length > 0) {
// ⚠️ Production: Nên hiển thị trên UI, không dùng alert
console.error('Form validation errors:', errors);
alert('Lỗi:\n\n' + errors.join('\n'));
// Focus vào field đầu tiên bị lỗi (advanced)
if (!data.fullName) {
event.target.elements.fullName.focus();
} else if (!emailRegex.test(data.email)) {
event.target.elements.email.focus();
}
return;
}
// Success
console.log('✅ Form valid:', data);
// Xóa sensitive data trước khi log
const safeData = { ...data };
delete safeData.password;
delete safeData.confirmPassword;
console.log('Safe to send:', safeData);
};
// Prevent paste vào confirm password (UX debatable)
const handleConfirmPasswordPaste = (event) => {
event.preventDefault();
alert('Vui lòng nhập lại mật khẩu thay vì copy/paste');
};
// Format phone number khi gõ
const handlePhoneInput = (event) => {
let value = event.target.value;
// Chỉ giữ lại số
value = value.replace(/[^0-9]/g, '');
// Limit 11 số
if (value.length > 11) {
value = value.slice(0, 11);
}
// ⚠️ Không thể set value vì không có state
// Chỉ log để demo
console.log('Phone formatted:', value);
// ℹ️ Để format realtime cần Controlled Component (Ngày 11)
};
return (
<form
onSubmit={handleSubmit}
noValidate
>
{/* noValidate: Tắt HTML validation để dùng custom */}
<div>
<label htmlFor='fullName'>
Họ tên: <span style={{ color: 'red' }}>*</span>
</label>
<input
type='text'
id='fullName'
name='fullName'
placeholder='Nguyễn Văn A'
autoComplete='name'
// ❌ KHÔNG dùng: defaultValue (để user tự nhập)
/>
</div>
<div>
<label htmlFor='email'>
Email: <span style={{ color: 'red' }}>*</span>
</label>
<input
type='email'
id='email'
name='email'
placeholder='example@email.com'
autoComplete='email'
/>
</div>
<div>
<label htmlFor='phone'>Số điện thoại:</label>
<input
type='tel'
id='phone'
name='phone'
placeholder='0901234567'
onInput={handlePhoneInput}
maxLength={11}
/>
</div>
<div>
<label htmlFor='age'>
Tuổi: <span style={{ color: 'red' }}>*</span>
</label>
<input
type='number'
id='age'
name='age'
min='18'
max='100'
defaultValue='25'
/>
</div>
<div>
<label htmlFor='password'>
Mật khẩu: <span style={{ color: 'red' }}>*</span>
</label>
<input
type='password'
id='password'
name='password'
placeholder='Ít nhất 8 ký tự'
autoComplete='new-password'
/>
<small style={{ color: '#666', display: 'block' }}>
Phải có chữ hoa, chữ thường và số
</small>
</div>
<div>
<label htmlFor='confirmPassword'>
Xác nhận mật khẩu: <span style={{ color: 'red' }}>*</span>
</label>
<input
type='password'
id='confirmPassword'
name='confirmPassword'
placeholder='Nhập lại mật khẩu'
autoComplete='new-password'
onPaste={handleConfirmPasswordPaste}
/>
</div>
<button type='submit'>Đăng ký</button>
<button type='reset'>Xóa form</button>
</form>
);
}🎯 Advanced Techniques:
// ✅ Sanitize input
const value = input.value.trim(); // Xóa whitespace
const cleaned = value.replace(/[^a-zA-Z0-9]/g, ''); // Chỉ giữ alphanumeric
// ✅ Cross-field validation
if (password !== confirmPassword) { /* error */ }
// ✅ Regex validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValid = emailRegex.test(email);
// ✅ Focus management
if (hasError) {
inputElement.focus(); // Focus vào field lỗi
}
// ✅ Prevent paste
<input onPaste={(e) => e.preventDefault()} />
// ✅ Format while typing (giới hạn do không có state)
<input onInput={handleFormat} />🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Exercise 1: Login Form Cơ Bản (15 phút)
/**
* 🎯 Mục tiêu: Tạo form đăng nhập đơn giản
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: useState, useEffect, useReducer
*
* Requirements:
* 1. Form có 2 fields: email và password
* 2. Validate khi submit:
* - Email không được rỗng và phải hợp lệ
* - Password phải ít nhất 6 ký tự
* 3. Hiển thị console.log khi login thành công
* 4. Reset form sau khi submit thành công
*
* 💡 Gợi ý:
* - Dùng event.preventDefault()
* - Dùng FormData hoặc event.target.elements
* - Dùng alert() hoặc console.error() cho errors
*/
// ❌ Cách SAI: Không validate
function LoginFormWrong() {
return (
<form
onSubmit={(e) => {
e.preventDefault();
// ❌ Submit trực tiếp mà không kiểm tra!
console.log('Logged in');
}}
>
<input
type='email'
name='email'
/>
<input
type='password'
name='password'
/>
<button type='submit'>Đăng nhập</button>
</form>
);
}
// ✅ Cách ĐÚNG: Có validation
function LoginFormCorrect() {
const handleSubmit = (event) => {
event.preventDefault();
const email = event.target.elements.email.value.trim();
const password = event.target.elements.password.value;
// Validation
const errors = [];
if (!email) {
errors.push('Email không được để trống');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push('Email không hợp lệ');
}
if (password.length < 6) {
errors.push('Mật khẩu phải ít nhất 6 ký tự');
}
if (errors.length > 0) {
alert('Lỗi:\n' + errors.join('\n'));
return;
}
// Success
console.log('✅ Đăng nhập thành công:', { email });
event.target.reset();
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor='email'>Email:</label>
<input
type='email'
id='email'
name='email'
placeholder='your@email.com'
required
/>
</div>
<div>
<label htmlFor='password'>Mật khẩu:</label>
<input
type='password'
id='password'
name='password'
placeholder='Ít nhất 6 ký tự'
required
/>
</div>
<button type='submit'>Đăng nhập</button>
</form>
);
}
// 🎯 NHIỆM VỤ CỦA BẠN:
function LoginForm() {
// TODO: Implement handleSubmit function
// TODO: Validate email (không rỗng + format hợp lệ)
// TODO: Validate password (>= 6 chars)
// TODO: Console.log khi success
// TODO: Reset form sau khi submit
return (
<form /* TODO: onSubmit handler */>
{/* TODO: Email input với label */}
{/* TODO: Password input với label */}
{/* TODO: Submit button */}
</form>
);
}✅ Self-Check:
- [ ] Form có email và password inputs?
- [ ] Email được validate (empty + format)?
- [ ] Password được validate (min length)?
- [ ] Console.log hiển thị data khi success?
- [ ] Form reset sau submit?
💡 Solution
/**
* Login Form Cơ Bản - Uncontrolled pattern
* Validate email và password khi submit
* Không sử dụng state (useState)
*/
function LoginForm() {
const handleSubmit = (event) => {
event.preventDefault();
// Lấy giá trị từ form
const email = event.target.elements.email.value.trim();
const password = event.target.elements.password.value;
// Validation
const errors = [];
// Kiểm tra email
if (!email) {
errors.push('Email không được để trống');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push('Email không hợp lệ');
}
// Kiểm tra password
if (password.length < 6) {
errors.push('Mật khẩu phải ít nhất 6 ký tự');
}
// Có lỗi → hiển thị và dừng
if (errors.length > 0) {
alert('Lỗi:\n' + errors.join('\n'));
return;
}
// Thành công
console.log('✅ Đăng nhập thành công:', { email });
// Reset form
event.target.reset();
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor='email'>Email:</label>
<input
type='email'
id='email'
name='email'
placeholder='your@email.com'
required
/>
</div>
<div>
<label htmlFor='password'>Mật khẩu:</label>
<input
type='password'
id='password'
name='password'
placeholder='Ít nhất 6 ký tự'
required
/>
</div>
<button type='submit'>Đăng nhập</button>
</form>
);
}
/* Ví dụ kết quả console khi submit thành công:
✅ Đăng nhập thành công: { email: "test@example.com" }
*/⭐⭐ Exercise 2: Survey Form với Multiple Input Types (25 phút)
/**
* 🎯 Mục tiêu: Xử lý nhiều loại input khác nhau
* ⏱️ Thời gian: 25 phút
*
* Scenario: Tạo form khảo sát sản phẩm
*
* Requirements:
* 1. Text input: Tên người dùng (required, min 3 chars)
* 2. Email input: Email (required, valid format)
* 3. Radio buttons: Độ tuổi (18-25, 26-35, 36-50, 50+)
* 4. Checkboxes: Sản phẩm quan tâm (ít nhất 1)
* - Laptop
* - Smartphone
* - Tablet
* - Accessories
* 5. Select: Tần suất mua hàng
* - Hàng tuần
* - Hàng tháng
* - Hàng quý
* - Hàng năm
* 6. Textarea: Góp ý (optional, max 500 chars)
*
* 🤔 PHÂN TÍCH:
* Approach A: Validate tất cả cùng lúc khi submit
* Pros: Code đơn giản, ít event handlers
* Cons: User chỉ biết lỗi sau khi click submit
*
* Approach B: Validate từng field khi blur
* Pros: UX tốt hơn, realtime feedback
* Cons: ⚠️ Cần state để lưu errors (chưa học!)
*
* 💭 BẠN CHỌN GÌ VÀ TẠI SAO?
* - Với kiến thức hiện tại (không có state), chọn Approach A
* - Document: "Approach A được chọn vì chưa học state management"
*/
// 🎯 NHIỆM VỤ CỦA BẠN:
// Implement form theo requirements ở trên✅ Self-Check:
- [ ] Có đủ các loại input (text, email, radio, checkbox, select, textarea)?
- [ ] Radio buttons dùng cùng
nameđể chỉ chọn 1? - [ ] Checkboxes dùng
getAll()để lấy nhiều giá trị? - [ ] Select có default value rỗng + disabled?
- [ ] Validation kiểm tra ít nhất 1 checkbox được chọn?
- [ ] Textarea có maxLength?
💡 Solution
/**
* Survey Form với Multiple Input Types - Uncontrolled pattern
* Xử lý nhiều loại input khác nhau và validate khi submit
* Approach A được chọn vì chưa học state management
*/
function SurveyForm() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
// Extract data
const data = {
name: formData.get('name')?.trim() || '',
email: formData.get('email')?.trim() || '',
ageGroup: formData.get('ageGroup'),
interests: formData.getAll('interests'),
frequency: formData.get('frequency'),
feedback: formData.get('feedback')?.trim() || '',
};
// Validation
const errors = [];
// 1. Tên người dùng
if (!data.name || data.name.length < 3) {
errors.push('Tên phải ít nhất 3 ký tự');
}
// 2. Email
if (!data.email) {
errors.push('Email không được để trống');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push('Email không hợp lệ');
}
// 3. Độ tuổi
if (!data.ageGroup) {
errors.push('Vui lòng chọn độ tuổi');
}
// 4. Sản phẩm quan tâm (ít nhất 1)
if (data.interests.length === 0) {
errors.push('Vui lòng chọn ít nhất 1 sản phẩm quan tâm');
}
// 5. Tần suất mua hàng
if (!data.frequency) {
errors.push('Vui lòng chọn tần suất mua hàng');
}
// 6. Góp ý (optional, max 500 ký tự)
if (data.feedback && data.feedback.length > 500) {
errors.push('Góp ý không được quá 500 ký tự');
}
// Hiển thị lỗi nếu có
if (errors.length > 0) {
alert('Lỗi:\n\n' + errors.join('\n'));
return;
}
// Thành công
console.log('✅ Survey submitted:', data);
// Reset form
event.target.reset();
};
return (
<form onSubmit={handleSubmit}>
{/* 1. Tên người dùng */}
<div>
<label htmlFor='name'>Tên của bạn: *</label>
<input
type='text'
id='name'
name='name'
placeholder='Nguyễn Văn A'
required
/>
</div>
{/* 2. Email */}
<div>
<label htmlFor='email'>Email: *</label>
<input
type='email'
id='email'
name='email'
placeholder='email@example.com'
required
/>
</div>
{/* 3. Radio buttons - Độ tuổi */}
<fieldset>
<legend>Độ tuổi: *</legend>
<label>
<input
type='radio'
name='ageGroup'
value='18-25'
required
/>{' '}
18-25
</label>
<label>
<input
type='radio'
name='ageGroup'
value='26-35'
/>{' '}
26-35
</label>
<label>
<input
type='radio'
name='ageGroup'
value='36-50'
/>{' '}
36-50
</label>
<label>
<input
type='radio'
name='ageGroup'
value='50+'
/>{' '}
50+
</label>
</fieldset>
{/* 4. Checkboxes - Sản phẩm quan tâm */}
<fieldset>
<legend>Sản phẩm quan tâm: * (chọn ít nhất 1)</legend>
<label>
<input
type='checkbox'
name='interests'
value='laptop'
/>{' '}
Laptop
</label>
<label>
<input
type='checkbox'
name='interests'
value='smartphone'
/>{' '}
Smartphone
</label>
<label>
<input
type='checkbox'
name='interests'
value='tablet'
/>{' '}
Tablet
</label>
<label>
<input
type='checkbox'
name='interests'
value='accessories'
/>{' '}
Accessories
</label>
</fieldset>
{/* 5. Select - Tần suất mua hàng */}
<div>
<label htmlFor='frequency'>Tần suất mua hàng: *</label>
<select
id='frequency'
name='frequency'
defaultValue=''
required
>
<option
value=''
disabled
>
-- Chọn tần suất --
</option>
<option value='weekly'>Hàng tuần</option>
<option value='monthly'>Hàng tháng</option>
<option value='quarterly'>Hàng quý</option>
<option value='yearly'>Hàng năm</option>
</select>
</div>
{/* 6. Textarea - Góp ý */}
<div>
<label htmlFor='feedback'>Góp ý thêm:</label>
<textarea
id='feedback'
name='feedback'
rows={4}
maxLength={500}
placeholder='Chia sẻ ý kiến của bạn... (tối đa 500 ký tự)'
/>
</div>
<button type='submit'>Gửi khảo sát</button>
</form>
);
}
/* Ví dụ kết quả console khi submit thành công:
✅ Survey submitted: {
name: "Nguyễn Văn A",
email: "vana@example.com",
ageGroup: "26-35",
interests: ["laptop", "smartphone"],
frequency: "monthly",
feedback: "Sản phẩm rất tốt, giao hàng nhanh"
}
*/⭐⭐⭐ Exercise 3: Dynamic Shipping Form (40 phút)
/**
* 🎯 Mục tiêu: Form với conditional fields
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là khách hàng, tôi muốn nhập địa chỉ giao hàng,
* với option địa chỉ thanh toán khác địa chỉ giao hàng"
*
* ✅ Acceptance Criteria:
* - [ ] Form có shipping address (họ tên, địa chỉ, thành phố, zip)
* - [ ] Checkbox "Địa chỉ thanh toán khác địa chỉ giao hàng"
* - [ ] Khi check: Hiển thị billing address form
* - [ ] Khi uncheck: Ẩn billing form, dùng shipping address
* - [ ] Validate tất cả required fields
* - [ ] Console.log kết quả khi submit
*
* 🎨 Technical Constraints:
* - KHÔNG dùng state (chưa học)
* - Dùng CSS để show/hide billing form
* - Validate cả shipping và billing addresses
*
* 🚨 Edge Cases cần handle:
* - User check/uncheck checkbox nhiều lần
* - Submit khi billing form đang ẩn
* - Validate fields trong form đang ẩn
*
* 📝 Implementation Checklist:
* - [ ] Shipping address form (4 fields)
* - [ ] Billing checkbox với onChange handler
* - [ ] Billing address form (initially hidden)
* - [ ] CSS class để show/hide
* - [ ] Validation logic
* - [ ] Submit handler tổng hợp data
*/
// 🎯 NHIỆM VỤ CỦA BẠN:
// Implement theo requirements🎯 Challenges:
- Show/hide billing section WITHOUT state
- Clear billing fields khi ẩn
- Validate conditional fields
- Handle edge cases (check/uncheck nhiều lần)
💡 Solution
/**
* Dynamic Shipping Form - Uncontrolled + DOM manipulation
* Hiển thị/ẩn billing address dựa trên checkbox
* Validate conditional fields mà không dùng state
*/
function ShippingForm() {
const handleBillingToggle = (event) => {
const billingSection = document.getElementById('billing-section');
if (!billingSection) return;
if (event.target.checked) {
billingSection.style.display = 'block';
} else {
billingSection.style.display = 'none';
// Xóa dữ liệu billing khi ẩn để tránh gửi nhầm dữ liệu cũ
const inputs = billingSection.querySelectorAll('input');
inputs.forEach((input) => {
input.value = '';
});
}
};
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
// Lấy dữ liệu shipping
const shipping = {
name: (formData.get('shipping_name') || '').trim(),
address: (formData.get('shipping_address') || '').trim(),
city: (formData.get('shipping_city') || '').trim(),
zip: (formData.get('shipping_zip') || '').trim(),
};
// Kiểm tra xem có dùng billing riêng không
const useSeparateBilling = !!formData.get('separate_billing');
// Lấy dữ liệu billing (nếu có)
let billing = shipping; // mặc định dùng shipping
if (useSeparateBilling) {
billing = {
name: (formData.get('billing_name') || '').trim(),
address: (formData.get('billing_address') || '').trim(),
city: (formData.get('billing_city') || '').trim(),
zip: (formData.get('billing_zip') || '').trim(),
};
}
// Validation
const errors = [];
// Shipping luôn bắt buộc
if (!shipping.name) errors.push('Shipping: Họ tên không được để trống');
if (!shipping.address) errors.push('Shipping: Địa chỉ không được để trống');
if (!shipping.city) errors.push('Shipping: Thành phố không được để trống');
if (!shipping.zip) errors.push('Shipping: Mã ZIP không được để trống');
// Billing chỉ validate khi được chọn (và hiển thị)
if (useSeparateBilling) {
if (!billing.name) errors.push('Billing: Họ tên không được để trống');
if (!billing.address) errors.push('Billing: Địa chỉ không được để trống');
if (!billing.city) errors.push('Billing: Thành phố không được để trống');
if (!billing.zip) errors.push('Billing: Mã ZIP không được để trống');
}
if (errors.length > 0) {
alert('Lỗi:\n\n' + errors.join('\n'));
return;
}
// Thành công
console.log('✅ Order submitted:', {
shipping,
billing,
useSeparateBilling,
});
// Reset form (tùy chọn)
event.target.reset();
// Đảm bảo billing section ẩn lại sau reset
const billingSection = document.getElementById('billing-section');
if (billingSection) {
billingSection.style.display = 'none';
}
};
return (
<form onSubmit={handleSubmit}>
<h3>Địa chỉ giao hàng</h3>
<div>
<label htmlFor='shipping_name'>Họ tên: *</label>
<input
type='text'
id='shipping_name'
name='shipping_name'
required
/>
</div>
<div>
<label htmlFor='shipping_address'>Địa chỉ: *</label>
<input
type='text'
id='shipping_address'
name='shipping_address'
required
/>
</div>
<div>
<label htmlFor='shipping_city'>Thành phố: *</label>
<input
type='text'
id='shipping_city'
name='shipping_city'
required
/>
</div>
<div>
<label htmlFor='shipping_zip'>Mã ZIP: *</label>
<input
type='text'
id='shipping_zip'
name='shipping_zip'
pattern='[0-9]{5}'
placeholder='Ví dụ: 700000'
title='Mã ZIP gồm 5 chữ số'
required
/>
</div>
<hr />
<div>
<label>
<input
type='checkbox'
name='separate_billing'
onChange={handleBillingToggle}
/>{' '}
Địa chỉ thanh toán khác địa chỉ giao hàng
</label>
</div>
{/* Billing section - ban đầu ẩn */}
<div
id='billing-section'
style={{ display: 'none' }}
>
<h3>Địa chỉ thanh toán</h3>
<div>
<label htmlFor='billing_name'>Họ tên: *</label>
<input
type='text'
id='billing_name'
name='billing_name'
/>
</div>
<div>
<label htmlFor='billing_address'>Địa chỉ: *</label>
<input
type='text'
id='billing_address'
name='billing_address'
/>
</div>
<div>
<label htmlFor='billing_city'>Thành phố: *</label>
<input
type='text'
id='billing_city'
name='billing_city'
/>
</div>
<div>
<label htmlFor='billing_zip'>Mã ZIP: *</label>
<input
type='text'
id='billing_zip'
name='billing_zip'
pattern='[0-9]{5}'
placeholder='Ví dụ: 700000'
title='Mã ZIP gồm 5 chữ số'
/>
</div>
</div>
<button type='submit'>Đặt hàng</button>
</form>
);
}
/* Ví dụ kết quả console khi submit thành công (billing khác shipping):
✅ Order submitted: {
shipping: {
name: "Nguyễn Văn A",
address: "123 Đường Láng",
city: "Hà Nội",
zip: "100000"
},
billing: {
name: "Công ty XYZ",
address: "456 Nguyễn Huệ",
city: "TP.HCM",
zip: "700000"
},
useSeparateBilling: true
}
Ví dụ khi không check checkbox (dùng chung địa chỉ):
✅ Order submitted: {
shipping: { ... },
billing: { ...same as shipping... },
useSeparateBilling: false
}
*/⭐⭐⭐⭐ Exercise 4: Multi-Step Form Architecture (60 phút)
/**
* 🎯 Mục tiêu: Thiết kế form nhiều bước mà không dùng state
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Nhiệm vụ:
* 1. So sánh ít nhất 3 approaches để làm multi-step form
* 2. Document pros/cons mỗi approach
* 3. Chọn approach phù hợp nhất (không cần state)
* 4. Viết ADR (Architecture Decision Record)
*
* Approach Options:
* A. Hidden sections với CSS (show/hide)
* B. Multiple <form> elements
* C. Single form với navigation buttons
* D. URL-based steps (query params) - Advanced!
*
* ADR Template:
* - Context: Multi-step registration form (3 steps)
* - Decision: Approach đã chọn
* - Rationale: Tại sao chọn approach này
* - Consequences: Trade-offs accepted
* - Alternatives Considered: Các options khác
*/
/**
* 💻 PHASE 2: Implementation (30 phút)
*
* Requirements:
* Step 1: Account Info
* - Username (min 3 chars)
* - Email (valid format)
* - Password (min 8 chars)
*
* Step 2: Personal Info
* - Full Name (min 3 chars)
* - Date of Birth (18+ years old)
* - Phone Number (10 digits)
*
* Step 3: Preferences
* - Newsletter (checkbox)
* - Interests (checkboxes - min 1)
* - Bio (textarea, max 200 chars)
*
* Features:
* - Validate each step before proceeding
* - Show current step indicator (1/3, 2/3, 3/3)
* - Back button (except Step 1)
* - Next button (Steps 1-2)
* - Submit button (Step 3)
* - Preserve data khi navigate steps
*/
// Example Solution using Hidden Sections
function MultiStepForm() {
// Step data stored in form fields (no state needed!)
const showStep = (stepNumber) => {
// Hide all steps
document.querySelectorAll('.form-step').forEach((step) => {
step.style.display = 'none';
});
// Show target step
document.getElementById(`step-${stepNumber}`).style.display = 'block';
// Update indicator
document.getElementById('step-indicator').textContent =
`Bước ${stepNumber}/3`;
};
const validateStep1 = () => {
const username = document.getElementById('username').value.trim();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const errors = [];
if (username.length < 3) {
errors.push('Username phải ít nhất 3 ký tự');
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push('Email không hợp lệ');
}
if (password.length < 8) {
errors.push('Mật khẩu phải ít nhất 8 ký tự');
}
return errors;
};
const validateStep2 = () => {
const fullName = document.getElementById('fullName').value.trim();
const dob = document.getElementById('dob').value;
const phone = document.getElementById('phone').value;
const errors = [];
if (fullName.length < 3) {
errors.push('Họ tên phải ít nhất 3 ký tự');
}
// Check age >= 18
const birthDate = new Date(dob);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
if (age < 18) {
errors.push('Bạn phải từ 18 tuổi trở lên');
}
if (!/^[0-9]{10}$/.test(phone)) {
errors.push('Số điện thoại phải là 10 chữ số');
}
return errors;
};
const handleNext1 = () => {
const errors = validateStep1();
if (errors.length > 0) {
alert('Lỗi:\n' + errors.join('\n'));
return;
}
showStep(2);
};
const handleNext2 = () => {
const errors = validateStep2();
if (errors.length > 0) {
alert('Lỗi:\n' + errors.join('\n'));
return;
}
showStep(3);
};
const handleBack2 = () => showStep(1);
const handleBack3 = () => showStep(2);
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
// Validate Step 3
const interests = formData.getAll('interests');
if (interests.length === 0) {
alert('Vui lòng chọn ít nhất 1 sở thích');
return;
}
// Collect all data
const data = {
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
fullName: formData.get('fullName'),
dob: formData.get('dob'),
phone: formData.get('phone'),
newsletter: !!formData.get('newsletter'),
interests: interests,
bio: formData.get('bio'),
};
console.log('✅ Registration complete:', data);
};
return (
<form onSubmit={handleSubmit}>
<div
id='step-indicator'
style={{ fontWeight: 'bold', marginBottom: '20px' }}
>
Bước 1/3
</div>
{/* Step 1: Account Info */}
<div
id='step-1'
className='form-step'
>
<h3>Thông tin tài khoản</h3>
<div>
<label htmlFor='username'>Username:</label>
<input
type='text'
id='username'
name='username'
required
/>
</div>
<div>
<label htmlFor='email'>Email:</label>
<input
type='email'
id='email'
name='email'
required
/>
</div>
<div>
<label htmlFor='password'>Mật khẩu:</label>
<input
type='password'
id='password'
name='password'
required
/>
</div>
<button
type='button'
onClick={handleNext1}
>
Tiếp theo →
</button>
</div>
{/* Step 2: Personal Info */}
<div
id='step-2'
className='form-step'
style={{ display: 'none' }}
>
<h3>Thông tin cá nhân</h3>
<div>
<label htmlFor='fullName'>Họ tên:</label>
<input
type='text'
id='fullName'
name='fullName'
required
/>
</div>
<div>
<label htmlFor='dob'>Ngày sinh:</label>
<input
type='date'
id='dob'
name='dob'
required
/>
</div>
<div>
<label htmlFor='phone'>Số điện thoại:</label>
<input
type='tel'
id='phone'
name='phone'
pattern='[0-9]{10}'
required
/>
</div>
<button
type='button'
onClick={handleBack2}
>
← Quay lại
</button>
<button
type='button'
onClick={handleNext2}
>
Tiếp theo →
</button>
</div>
{/* Step 3: Preferences */}
<div
id='step-3'
className='form-step'
style={{ display: 'none' }}
>
<h3>Sở thích</h3>
<div>
<label>
<input
type='checkbox'
name='newsletter'
/>{' '}
Nhận bản tin
</label>
</div>
<fieldset>
<legend>Sở thích: *</legend>
<label>
<input
type='checkbox'
name='interests'
value='tech'
/>{' '}
Công nghệ
</label>
<label>
<input
type='checkbox'
name='interests'
value='sports'
/>{' '}
Thể thao
</label>
<label>
<input
type='checkbox'
name='interests'
value='music'
/>{' '}
Âm nhạc
</label>
</fieldset>
<div>
<label htmlFor='bio'>Giới thiệu bản thân:</label>
<textarea
id='bio'
name='bio'
rows={4}
maxLength={200}
/>
</div>
<button
type='button'
onClick={handleBack3}
>
← Quay lại
</button>
<button type='submit'>Hoàn tất đăng ký</button>
</div>
</form>
);
}
// 🎯 NHIỆM VỤ CỦA BẠN:
/**
* 1. Viết ADR document (20 phút)
* 2. Implement solution (30 phút)
* 3. Test all flows (10 phút):
* - Next/Back navigation
* - Validation mỗi step
* - Data preservation
* - Final submit
*/💡 Solution
/**
* Multi-Step Registration Form - Uncontrolled + DOM manipulation
* Sử dụng approach Hidden sections với CSS show/hide
* Không dùng state, dữ liệu được bảo toàn trong các input fields
*/
/*
* ADR (Architecture Decision Record)
*
* Context:
* Xây dựng multi-step registration form với 3 bước:
* - Step 1: Account Info
* - Step 2: Personal Info
* - Step 3: Preferences
* Yêu cầu: Không được dùng state (useState/useReducer), cần validate từng bước,
* giữ dữ liệu khi chuyển bước, có nút Back/Next/Submit và indicator.
*
* Decision:
* Sử dụng Approach A - Hidden sections với CSS (show/hide)
*
* Rationale:
* - Đơn giản nhất trong các lựa chọn khi không dùng state
* - Dữ liệu tự động được bảo toàn trong DOM (input values)
* - Dễ validate từng bước bằng cách query DOM elements
* - Không cần quản lý routing hay nhiều form
* - Có thể điều khiển hiển thị hoàn toàn bằng JavaScript + CSS
*
* Consequences / Trade-offs accepted:
* - Phải dùng document.getElementById / querySelector → không "React way"
* - Code validation hơi dài dòng vì phải query từng field
* - Không tận dụng được React re-rendering cho UI updates
* - Khó scale nếu có nhiều bước hoặc logic phức tạp hơn
*
* Alternatives Considered:
* B. Multiple <form> elements
* → Không khả thi vì khó giữ dữ liệu giữa các form riêng biệt
* C. Single form với navigation buttons (đã chọn)
* → Đây chính là biến thể của A, được triển khai
* D. URL-based steps (query params)
* → Quá phức tạp, cần xử lý history, không cần thiết cho bài tập
*/
function MultiStepForm() {
// Hiển thị bước cụ thể và cập nhật indicator
const showStep = (stepNumber) => {
document.querySelectorAll('.form-step').forEach((step) => {
step.style.display = 'none';
});
const targetStep = document.getElementById(`step-${stepNumber}`);
if (targetStep) {
targetStep.style.display = 'block';
}
const indicator = document.getElementById('step-indicator');
if (indicator) {
indicator.textContent = `Bước ${stepNumber}/3`;
}
};
// Validate Step 1: Account Info
const validateStep1 = () => {
const username = document.getElementById('username')?.value.trim() || '';
const email = document.getElementById('email')?.value.trim() || '';
const password = document.getElementById('password')?.value || '';
const errors = [];
if (username.length < 3) {
errors.push('Username phải ít nhất 3 ký tự');
}
if (!email) {
errors.push('Email không được để trống');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push('Email không hợp lệ');
}
if (password.length < 8) {
errors.push('Mật khẩu phải ít nhất 8 ký tự');
}
return errors;
};
// Validate Step 2: Personal Info
const validateStep2 = () => {
const fullName = document.getElementById('fullName')?.value.trim() || '';
const dob = document.getElementById('dob')?.value || '';
const phone = document.getElementById('phone')?.value || '';
const errors = [];
if (fullName.length < 3) {
errors.push('Họ tên phải ít nhất 3 ký tự');
}
if (dob) {
const birthDate = new Date(dob);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
if (age < 18) {
errors.push('Bạn phải từ 18 tuổi trở lên');
}
} else {
errors.push('Vui lòng nhập ngày sinh');
}
if (!/^[0-9]{10}$/.test(phone)) {
errors.push('Số điện thoại phải đúng 10 chữ số');
}
return errors;
};
// Next từ Step 1
const handleNext1 = () => {
const errors = validateStep1();
if (errors.length > 0) {
alert('Lỗi ở bước 1:\n' + errors.join('\n'));
return;
}
showStep(2);
};
// Next từ Step 2
const handleNext2 = () => {
const errors = validateStep2();
if (errors.length > 0) {
alert('Lỗi ở bước 2:\n' + errors.join('\n'));
return;
}
showStep(3);
};
// Back buttons
const handleBack2 = () => showStep(1);
const handleBack3 = () => showStep(2);
// Final submit - validate step 3
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const interests = formData.getAll('interests');
if (interests.length === 0) {
alert('Vui lòng chọn ít nhất 1 sở thích ở bước 3');
return;
}
// Thu thập toàn bộ dữ liệu
const data = {
username: formData.get('username'),
email: formData.get('email'),
password: formData.get('password'),
fullName: formData.get('fullName'),
dob: formData.get('dob'),
phone: formData.get('phone'),
newsletter: !!formData.get('newsletter'),
interests: interests,
bio: formData.get('bio') || '',
};
console.log('✅ Registration complete:', data);
// Optional: reset form sau khi thành công
// event.target.reset();
// showStep(1);
};
// Khởi tạo: hiển thị bước 1
// (thường đặt trong useEffect, nhưng ở đây gọi trực tiếp)
// Vì là uncontrolled, ta để JSX render trước rồi mới gọi showStep(1)
// Nhưng trong React, tốt nhất nên gọi trong useEffect → ở đây ta giả lập bằng setTimeout ngắn
setTimeout(() => {
showStep(1);
}, 0);
return (
<form onSubmit={handleSubmit}>
<div
id='step-indicator'
style={{ fontWeight: 'bold', marginBottom: '24px', fontSize: '1.2rem' }}
>
Bước 1/3
</div>
{/* Step 1 */}
<div
id='step-1'
className='form-step'
>
<h3>Thông tin tài khoản</h3>
<div>
<label htmlFor='username'>Username:</label>
<input
type='text'
id='username'
name='username'
required
/>
</div>
<div>
<label htmlFor='email'>Email:</label>
<input
type='email'
id='email'
name='email'
required
/>
</div>
<div>
<label htmlFor='password'>Mật khẩu:</label>
<input
type='password'
id='password'
name='password'
required
/>
</div>
<div style={{ marginTop: '20px' }}>
<button
type='button'
onClick={handleNext1}
>
Tiếp theo →
</button>
</div>
</div>
{/* Step 2 */}
<div
id='step-2'
className='form-step'
style={{ display: 'none' }}
>
<h3>Thông tin cá nhân</h3>
<div>
<label htmlFor='fullName'>Họ tên:</label>
<input
type='text'
id='fullName'
name='fullName'
required
/>
</div>
<div>
<label htmlFor='dob'>Ngày sinh:</label>
<input
type='date'
id='dob'
name='dob'
required
/>
</div>
<div>
<label htmlFor='phone'>Số điện thoại:</label>
<input
type='tel'
id='phone'
name='phone'
pattern='[0-9]{10}'
title='Số điện thoại phải có đúng 10 chữ số'
required
/>
</div>
<div style={{ marginTop: '20px' }}>
<button
type='button'
onClick={handleBack2}
>
← Quay lại
</button>
<button
type='button'
onClick={handleNext2}
>
Tiếp theo →
</button>
</div>
</div>
{/* Step 3 */}
<div
id='step-3'
className='form-step'
style={{ display: 'none' }}
>
<h3>Sở thích</h3>
<div>
<label>
<input
type='checkbox'
name='newsletter'
/>
Nhận bản tin
</label>
</div>
<fieldset>
<legend>Sở thích: *</legend>
<label>
<input
type='checkbox'
name='interests'
value='tech'
/>
Công nghệ
</label>
<label>
<input
type='checkbox'
name='interests'
value='sports'
/>
Thể thao
</label>
<label>
<input
type='checkbox'
name='interests'
value='music'
/>
Âm nhạc
</label>
<label>
<input
type='checkbox'
name='interests'
value='travel'
/>
Du lịch
</label>
</fieldset>
<div>
<label htmlFor='bio'>Giới thiệu bản thân:</label>
<textarea
id='bio'
name='bio'
rows={4}
maxLength={200}
placeholder='Tối đa 200 ký tự'
/>
</div>
<div style={{ marginTop: '20px' }}>
<button
type='button'
onClick={handleBack3}
>
← Quay lại
</button>
<button type='submit'>Hoàn tất đăng ký</button>
</div>
</div>
</form>
);
}
/* Ví dụ kết quả console khi submit thành công:
✅ Registration complete: {
username: "nguyenvana",
email: "vana@example.com",
password: "MatKhau123",
fullName: "Nguyễn Văn A",
dob: "1995-05-20",
phone: "0912345678",
newsletter: true,
interests: ["tech", "music"],
bio: "Yêu thích công nghệ và âm nhạc hiện đại."
}
*/⭐⭐⭐⭐⭐ Exercise 5: Production-Ready Contact Form (90 phút)
/**
* 🎯 Mục tiêu: Form đạt chuẩn production
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
* Tạo contact form cho enterprise website với các yêu cầu:
*
* Functional Requirements:
* - Name, Email, Phone, Subject, Message fields
* - Department dropdown (Sales, Support, HR, Other)
* - Priority radio (Low, Medium, High, Urgent)
* - File attachment (optional, max 5MB, pdf/doc/docx only)
* - Privacy policy checkbox (required)
* - Form submission với validation đầy đủ
* - Success/Error feedback
* - Form reset sau submit thành công
*
* Non-Functional Requirements:
* - Accessibility (WCAG 2.1 Level AA)
* - Responsive design
* - Loading state during submission
* - Error messages inline & summary
* - Client-side validation
* - Security considerations
*
* 🏗️ Technical Design Doc:
*
* 1. Component Architecture
* - Single ContactForm component
* - Helper validation functions
* - Error display component (inline)
*
* 2. State Management Strategy
* - NO STATE (uncontrolled form)
* - DOM manipulation for error display
* - FormData API for value extraction
*
* 3. API Integration Points
* - Mock API endpoint (console.log)
* - Timeout để simulate network delay
*
* 4. Performance Considerations
* - Debounce file validation
* - Lazy load file reader
*
* 5. Error Handling Strategy
* - Inline errors per field
* - Error summary at top
* - Focus management
*
* ✅ Production Checklist:
* - [ ] All inputs have labels (for screen readers)
* - [ ] Required fields marked with *
* - [ ] ARIA attributes (aria-required, aria-invalid)
* - [ ] Error messages with role="alert"
* - [ ] Keyboard navigation works
* - [ ] Form submits with Enter key
* - [ ] Loading state prevents double submit
* - [ ] File upload validation (size, type)
* - [ ] XSS prevention (sanitize input)
* - [ ] Responsive layout (mobile-first)
* - [ ] High contrast mode support
* - [ ] Focus visible indicators
*
* 📝 Documentation:
* - Inline comments giải thích logic
* - JSDoc cho functions
* - Usage examples
*/
/**
* File upload validator
* @param {File} file
* @returns {string|null} Error message hoặc null nếu valid
*/
function validateFile(file) {
if (!file) return null;
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (file.size > MAX_SIZE) {
return `File quá lớn. Tối đa 5MB (file của bạn: ${(file.size / 1024 / 1024).toFixed(2)}MB)`;
}
if (!ALLOWED_TYPES.includes(file.type)) {
return 'Chỉ chấp nhận file PDF, DOC, DOCX';
}
return null;
}
/**
* Sanitize user input để prevent XSS
* @param {string} str
* @returns {string}
*/
function sanitizeInput(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function ProductionContactForm() {
// Track submission state
let isSubmitting = false;
/**
* Display error message for specific field
*/
const showFieldError = (fieldName, message) => {
const errorId = `${fieldName}-error`;
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = errorId;
errorEl.className = 'field-error';
errorEl.setAttribute('role', 'alert');
const field = document.getElementById(fieldName);
field.parentNode.appendChild(errorEl);
}
errorEl.textContent = message;
errorEl.style.display = 'block';
// Mark field as invalid for screen readers
const field = document.getElementById(fieldName);
field.setAttribute('aria-invalid', 'true');
};
/**
* Clear all error messages
*/
const clearErrors = () => {
document.querySelectorAll('.field-error').forEach((el) => {
el.style.display = 'none';
});
document.querySelectorAll('[aria-invalid]').forEach((el) => {
el.removeAttribute('aria-invalid');
});
const summary = document.getElementById('error-summary');
if (summary) {
summary.style.display = 'none';
}
};
/**
* Display error summary
*/
const showErrorSummary = (errors) => {
let summary = document.getElementById('error-summary');
if (!summary) {
summary = document.createElement('div');
summary.id = 'error-summary';
summary.className = 'error-summary';
summary.setAttribute('role', 'alert');
summary.setAttribute('aria-live', 'assertive');
const form = document.getElementById('contact-form');
form.insertBefore(summary, form.firstChild);
}
summary.innerHTML = `
<h3>Vui lòng sửa các lỗi sau:</h3>
<ul>
${errors.map((err) => `<li>${sanitizeInput(err)}</li>`).join('')}
</ul>
`;
summary.style.display = 'block';
// Focus vào summary để screen reader đọc
summary.focus();
};
/**
* Validate all form fields
*/
const validateForm = (formData, fileInput) => {
const errors = [];
// Extract data
const data = {
name: formData.get('name')?.trim(),
email: formData.get('email')?.trim(),
phone: formData.get('phone')?.trim(),
department: formData.get('department'),
priority: formData.get('priority'),
subject: formData.get('subject')?.trim(),
message: formData.get('message')?.trim(),
privacy: formData.get('privacy'),
file: fileInput.files[0],
};
// Clear previous errors
clearErrors();
// Validate name
if (!data.name || data.name.length < 2) {
errors.push('Tên phải ít nhất 2 ký tự');
showFieldError('name', 'Tên phải ít nhất 2 ký tự');
}
// Validate email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!data.email || !emailRegex.test(data.email)) {
errors.push('Email không hợp lệ');
showFieldError('email', 'Email không hợp lệ');
}
// Validate phone (optional but if provided must be valid)
if (data.phone) {
const phoneRegex = /^[0-9]{10,11}$/;
if (!phoneRegex.test(data.phone)) {
errors.push('Số điện thoại phải là 10-11 chữ số');
showFieldError('phone', 'Số điện thoại phải là 10-11 chữ số');
}
}
// Validate department
if (!data.department) {
errors.push('Vui lòng chọn phòng ban');
showFieldError('department', 'Vui lòng chọn phòng ban');
}
// Validate priority
if (!data.priority) {
errors.push('Vui lòng chọn mức độ ưu tiên');
}
// Validate subject
if (!data.subject || data.subject.length < 5) {
errors.push('Tiêu đề phải ít nhất 5 ký tự');
showFieldError('subject', 'Tiêu đề phải ít nhất 5 ký tự');
}
// Validate message
if (!data.message || data.message.length < 20) {
errors.push('Nội dung phải ít nhất 20 ký tự');
showFieldError('message', 'Nội dung phải ít nhất 20 ký tự');
}
// Validate privacy
if (!data.privacy) {
errors.push('Bạn phải đồng ý với chính sách bảo mật');
showFieldError('privacy', 'Bạn phải đồng ý với chính sách bảo mật');
}
// Validate file
const fileError = validateFile(data.file);
if (fileError) {
errors.push(fileError);
showFieldError('attachment', fileError);
}
return { valid: errors.length === 0, errors, data };
};
/**
* Simulate API call
*/
const submitToAPI = async (data) => {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 2000));
// Simulate random success/failure (90% success)
if (Math.random() > 0.1) {
return { success: true };
} else {
throw new Error('Server error. Please try again.');
}
};
/**
* Handle form submission
*/
const handleSubmit = async (event) => {
event.preventDefault();
// Prevent double submit
if (isSubmitting) {
return;
}
const form = event.target;
const formData = new FormData(form);
const fileInput = document.getElementById('attachment');
// Validate
const { valid, errors, data } = validateForm(formData, fileInput);
if (!valid) {
showErrorSummary(errors);
// Focus first error field
const firstErrorField = document.querySelector('[aria-invalid="true"]');
if (firstErrorField) {
firstErrorField.focus();
}
return;
}
// Show loading state
isSubmitting = true;
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.textContent = 'Đang gửi...';
submitBtn.disabled = true;
try {
// Submit to API
await submitToAPI(data);
// Success feedback
alert('✅ Gửi thành công! Chúng tôi sẽ liên hệ bạn sớm.');
// Reset form
form.reset();
clearErrors();
// Log success (in production: analytics tracking)
console.log('✅ Contact form submitted:', {
...data,
file: data.file
? {
name: data.file.name,
size: data.file.size,
type: data.file.type,
}
: null,
});
} catch (error) {
// Error feedback
alert('❌ Có lỗi xảy ra. Vui lòng thử lại sau.');
console.error('Submission error:', error);
} finally {
// Reset loading state
isSubmitting = false;
submitBtn.textContent = originalText;
submitBtn.disabled = false;
}
};
/**
* Handle file input change
*/
const handleFileChange = (event) => {
const file = event.target.files[0];
const error = validateFile(file);
if (error) {
showFieldError('attachment', error);
event.target.value = ''; // Clear invalid file
} else {
clearErrors();
}
};
return (
<form
id='contact-form'
onSubmit={handleSubmit}
noValidate // Use custom validation
style={{
maxWidth: '600px',
margin: '0 auto',
padding: '20px',
}}
>
<h2>Liên hệ với chúng tôi</h2>
<p>Điền thông tin bên dưới, chúng tôi sẽ liên hệ bạn sớm nhất.</p>
{/* Name */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='name'>
Họ tên:{' '}
<span
style={{ color: 'red' }}
aria-label='required'
>
*
</span>
</label>
<input
type='text'
id='name'
name='name'
aria-required='true'
autoComplete='name'
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Email */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='email'>
Email:{' '}
<span
style={{ color: 'red' }}
aria-label='required'
>
*
</span>
</label>
<input
type='email'
id='email'
name='email'
aria-required='true'
autoComplete='email'
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Phone */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='phone'>Số điện thoại:</label>
<input
type='tel'
id='phone'
name='phone'
autoComplete='tel'
placeholder='0901234567'
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Department */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='department'>
Phòng ban:{' '}
<span
style={{ color: 'red' }}
aria-label='required'
>
*
</span>
</label>
<select
id='department'
name='department'
aria-required='true'
defaultValue=''
style={{ width: '100%', padding: '8px' }}
>
<option
value=''
disabled
>
-- Chọn phòng ban --
</option>
<option value='sales'>Sales</option>
<option value='support'>Support</option>
<option value='hr'>HR</option>
<option value='other'>Other</option>
</select>
</div>
{/* Priority */}
<fieldset
style={{
marginBottom: '20px',
border: '1px solid #ccc',
padding: '10px',
}}
>
<legend>
Mức độ ưu tiên:{' '}
<span
style={{ color: 'red' }}
aria-label='required'
>
*
</span>
</legend>
<label style={{ display: 'block', marginBottom: '5px' }}>
<input
type='radio'
name='priority'
value='low'
/>{' '}
Thấp
</label>
<label style={{ display: 'block', marginBottom: '5px' }}>
<input
type='radio'
name='priority'
value='medium'
defaultChecked
/>{' '}
Trung bình
</label>
<label style={{ display: 'block', marginBottom: '5px' }}>
<input
type='radio'
name='priority'
value='high'
/>{' '}
Cao
</label>
<label style={{ display: 'block' }}>
<input
type='radio'
name='priority'
value='urgent'
/>{' '}
Khẩn cấp
</label>
</fieldset>
{/* Subject */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='subject'>
Tiêu đề:{' '}
<span
style={{ color: 'red' }}
aria-label='required'
>
*
</span>
</label>
<input
type='text'
id='subject'
name='subject'
aria-required='true'
placeholder='Tóm tắt nội dung liên hệ'
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Message */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='message'>
Nội dung:{' '}
<span
style={{ color: 'red' }}
aria-label='required'
>
*
</span>
</label>
<textarea
id='message'
name='message'
aria-required='true'
rows={6}
placeholder='Mô tả chi tiết yêu cầu của bạn (ít nhất 20 ký tự)'
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* File Attachment */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='attachment'>
Đính kèm file: <small>(PDF, DOC, DOCX - Max 5MB)</small>
</label>
<input
type='file'
id='attachment'
name='attachment'
accept='.pdf,.doc,.docx'
onChange={handleFileChange}
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Privacy Checkbox */}
<div style={{ marginBottom: '20px' }}>
<label>
<input
type='checkbox'
id='privacy'
name='privacy'
aria-required='true'
/>{' '}
<span>
Tôi đồng ý với{' '}
<a
href='/privacy'
target='_blank'
rel='noopener noreferrer'
>
chính sách bảo mật
</a>{' '}
<span
style={{ color: 'red' }}
aria-label='required'
>
*
</span>
</span>
</label>
</div>
{/* Submit Button */}
<button
type='submit'
style={{
backgroundColor: '#007bff',
color: 'white',
padding: '12px 24px',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px',
}}
>
Gửi liên hệ
</button>
{/* CSS for error messages */}
<style>{`
.field-error {
color: #d32f2f;
font-size: 14px;
margin-top: 4px;
display: none;
}
.error-summary {
background-color: #ffebee;
border-left: 4px solid #d32f2f;
padding: 16px;
margin-bottom: 24px;
display: none;
}
.error-summary h3 {
color: #d32f2f;
margin-top: 0;
}
.error-summary ul {
margin: 8px 0 0 0;
padding-left: 20px;
}
[aria-invalid="true"] {
border-color: #d32f2f;
border-width: 2px;
}
/* Focus styles for accessibility */
input:focus,
select:focus,
textarea:focus,
button:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
input, select, textarea {
border: 2px solid currentColor;
}
}
`}</style>
</form>
);
}
// 🎯 NHIỆM VỤ CỦA BẠN:
/**
* Implement production-ready contact form với:
* - ✅ Full validation (client-side)
* - ✅ Accessibility (WCAG 2.1 AA)
* - ✅ Error handling (inline + summary)
* - ✅ Loading states
* - ✅ File upload validation
* - ✅ Security (input sanitization)
* - ✅ Responsive design
* - ✅ Focus management
* - ✅ Screen reader support
*/🔍 Code Review Self-Checklist:
- [ ] All labels have
htmlForattribute? - [ ] Required fields marked with
aria-required="true"? - [ ] Error messages have
role="alert"? - [ ] Focus moves to errors?
- [ ] Keyboard navigation works?
- [ ] File upload validated (size + type)?
- [ ] Double submit prevented?
- [ ] Input sanitized before display?
- [ ] Loading state shown during submit?
- [ ] Success/error feedback clear?
💡 Solution
/**
* Production-Ready Contact Form (Uncontrolled + DOM manipulation)
* Đạt chuẩn accessibility WCAG 2.1 AA, client-side validation,
* error handling (inline + summary), loading state, file validation,
* input sanitization, focus management, không sử dụng state
*/
function ProductionContactForm() {
// Biến theo dõi trạng thái submit (không phải React state)
let isSubmitting = false;
/**
* Hiển thị lỗi cho một field cụ thể
* @param {string} fieldName - id của input
* @param {string} message - thông báo lỗi
*/
const showFieldError = (fieldName, message) => {
const errorId = `${fieldName}-error`;
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = errorId;
errorEl.className = 'field-error';
errorEl.setAttribute('role', 'alert');
errorEl.setAttribute('aria-live', 'assertive');
const field = document.getElementById(fieldName);
if (field && field.parentNode) {
field.parentNode.appendChild(errorEl);
}
}
errorEl.textContent = message;
errorEl.style.display = 'block';
const field = document.getElementById(fieldName);
if (field) {
field.setAttribute('aria-invalid', 'true');
field.setAttribute('aria-describedby', errorId);
}
};
/**
* Xóa toàn bộ lỗi hiển thị
*/
const clearErrors = () => {
document.querySelectorAll('.field-error').forEach((el) => {
el.style.display = 'none';
el.textContent = '';
});
document.querySelectorAll('[aria-invalid="true"]').forEach((el) => {
el.removeAttribute('aria-invalid');
el.removeAttribute('aria-describedby');
});
const summary = document.getElementById('error-summary');
if (summary) {
summary.style.display = 'none';
summary.innerHTML = '';
}
};
/**
* Hiển thị tóm tắt lỗi ở đầu form
* @param {string[]} errors
*/
const showErrorSummary = (errors) => {
let summary = document.getElementById('error-summary');
if (!summary) {
summary = document.createElement('div');
summary.id = 'error-summary';
summary.className = 'error-summary';
summary.setAttribute('role', 'alert');
summary.setAttribute('aria-live', 'assertive');
const form = document.getElementById('contact-form');
if (form) form.insertBefore(summary, form.firstChild);
}
summary.innerHTML = `
<h3>Vui lòng sửa các lỗi sau:</h3>
<ul>
${errors.map((err) => `<li>${sanitizeInput(err)}</li>`).join('')}
</ul>
`;
summary.style.display = 'block';
summary.focus(); // Hỗ trợ screen reader
};
/**
* Validate toàn bộ form
* @param {FormData} formData
* @param {HTMLInputElement} fileInput
* @returns {{valid: boolean, errors: string[], data: object}}
*/
const validateForm = (formData, fileInput) => {
const errors = [];
const data = {
name: (formData.get('name') || '').trim(),
email: (formData.get('email') || '').trim(),
phone: (formData.get('phone') || '').trim(),
department: formData.get('department'),
priority: formData.get('priority'),
subject: (formData.get('subject') || '').trim(),
message: (formData.get('message') || '').trim(),
privacy: formData.get('privacy'),
file: fileInput?.files?.[0] || null,
};
clearErrors();
// Name
if (!data.name || data.name.length < 2) {
errors.push('Họ tên phải ít nhất 2 ký tự');
showFieldError('name', 'Họ tên phải ít nhất 2 ký tự');
}
// Email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!data.email || !emailRegex.test(data.email)) {
errors.push('Email không hợp lệ');
showFieldError('email', 'Email không hợp lệ');
}
// Phone (optional)
if (data.phone && !/^[0-9]{10,11}$/.test(data.phone)) {
errors.push('Số điện thoại phải là 10-11 chữ số');
showFieldError('phone', 'Số điện thoại phải là 10-11 chữ số');
}
// Department
if (!data.department) {
errors.push('Vui lòng chọn phòng ban');
showFieldError('department', 'Vui lòng chọn phòng ban');
}
// Priority
if (!data.priority) {
errors.push('Vui lòng chọn mức độ ưu tiên');
// Không có id cho radio group → không show inline error cho group
}
// Subject
if (!data.subject || data.subject.length < 5) {
errors.push('Tiêu đề phải ít nhất 5 ký tự');
showFieldError('subject', 'Tiêu đề phải ít nhất 5 ký tự');
}
// Message
if (!data.message || data.message.length < 20) {
errors.push('Nội dung phải ít nhất 20 ký tự');
showFieldError('message', 'Nội dung phải ít nhất 20 ký tự');
}
// Privacy
if (!data.privacy) {
errors.push('Bạn phải đồng ý với chính sách bảo mật');
showFieldError('privacy', 'Bạn phải đồng ý với chính sách bảo mật');
}
// File
const fileError = validateFile(data.file);
if (fileError) {
errors.push(fileError);
showFieldError('attachment', fileError);
}
return { valid: errors.length === 0, errors, data };
};
/**
* Giả lập gọi API
* @param {object} data
*/
const submitToAPI = async (data) => {
await new Promise((resolve) => setTimeout(resolve, 1800));
// 92% thành công để test error hiếm
if (Math.random() > 0.08) {
return { success: true, message: 'Gửi thành công' };
}
throw new Error('Lỗi server tạm thời. Vui lòng thử lại sau.');
};
/**
* Xử lý submit form
*/
const handleSubmit = async (event) => {
event.preventDefault();
if (isSubmitting) return;
const form = event.target;
const formData = new FormData(form);
const fileInput = document.getElementById('attachment');
const { valid, errors, data } = validateForm(formData, fileInput);
if (!valid) {
showErrorSummary(errors);
const firstInvalid = document.querySelector('[aria-invalid="true"]');
if (firstInvalid) {
firstInvalid.focus();
}
return;
}
isSubmitting = true;
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Đang gửi...';
try {
await submitToAPI(data);
alert('✅ Gửi thành công! Chúng tôi sẽ phản hồi sớm nhất có thể.');
form.reset();
clearErrors();
// Reset file input visual
const fileLabel = document.querySelector('label[for="attachment"]');
if (fileLabel)
fileLabel.textContent = 'Đính kèm file: (PDF, DOC, DOCX - Max 5MB)';
console.log('✅ Contact form submitted:', {
...data,
file: data.file
? {
name: data.file.name,
size: `${(data.file.size / 1024 / 1024).toFixed(2)} MB`,
type: data.file.type,
}
: null,
});
} catch (err) {
alert(
'❌ ' + (err.message || 'Có lỗi xảy ra khi gửi. Vui lòng thử lại.'),
);
console.error('Submission failed:', err);
} finally {
isSubmitting = false;
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
};
/**
* Validate file ngay khi chọn
*/
const handleFileChange = (e) => {
const file = e.target.files[0];
const error = validateFile(file);
if (error) {
showFieldError('attachment', error);
e.target.value = '';
} else {
// Xóa lỗi nếu có
const errorEl = document.getElementById('attachment-error');
if (errorEl) errorEl.style.display = 'none';
document.getElementById('attachment')?.removeAttribute('aria-invalid');
}
};
return (
<form
id='contact-form'
onSubmit={handleSubmit}
noValidate
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px',
fontFamily: 'system-ui, sans-serif',
}}
>
<h2>Liên hệ với chúng tôi</h2>
<p>
Điền thông tin bên dưới, chúng tôi sẽ phản hồi trong vòng 24-48 giờ.
</p>
{/* Error Summary sẽ được tạo động */}
{/* Name */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='name'>
Họ tên{' '}
<span
style={{ color: 'red' }}
aria-label='bắt buộc'
>
*
</span>
</label>
<input
type='text'
id='name'
name='name'
aria-required='true'
autoComplete='name'
required
style={{ width: '100%', padding: '10px', boxSizing: 'border-box' }}
/>
</div>
{/* Email */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='email'>
Email{' '}
<span
style={{ color: 'red' }}
aria-label='bắt buộc'
>
*
</span>
</label>
<input
type='email'
id='email'
name='email'
aria-required='true'
autoComplete='email'
required
style={{ width: '100%', padding: '10px', boxSizing: 'border-box' }}
/>
</div>
{/* Phone */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='phone'>Số điện thoại</label>
<input
type='tel'
id='phone'
name='phone'
autoComplete='tel'
placeholder='Ví dụ: 0901234567'
style={{ width: '100%', padding: '10px', boxSizing: 'border-box' }}
/>
</div>
{/* Department */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='department'>
Phòng ban{' '}
<span
style={{ color: 'red' }}
aria-label='bắt buộc'
>
*
</span>
</label>
<select
id='department'
name='department'
aria-required='true'
required
style={{ width: '100%', padding: '10px', boxSizing: 'border-box' }}
>
<option
value=''
disabled
selected
>
-- Chọn phòng ban --
</option>
<option value='sales'>Sales</option>
<option value='support'>Support</option>
<option value='hr'>HR</option>
<option value='other'>Other</option>
</select>
</div>
{/* Priority */}
<fieldset
style={{
marginBottom: '24px',
border: '1px solid #ddd',
padding: '16px',
}}
>
<legend>
Mức độ ưu tiên{' '}
<span
style={{ color: 'red' }}
aria-label='bắt buộc'
>
*
</span>
</legend>
{['low', 'medium', 'high', 'urgent'].map((val, i) => (
<label
key={val}
style={{ display: 'block', marginBottom: '8px' }}
>
<input
type='radio'
name='priority'
value={val}
defaultChecked={i === 1}
required
/>{' '}
{val === 'low'
? 'Thấp'
: val === 'medium'
? 'Trung bình'
: val === 'high'
? 'Cao'
: 'Khẩn cấp'}
</label>
))}
</fieldset>
{/* Subject */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='subject'>
Tiêu đề{' '}
<span
style={{ color: 'red' }}
aria-label='bắt buộc'
>
*
</span>
</label>
<input
type='text'
id='subject'
name='subject'
aria-required='true'
placeholder='Tóm tắt nội dung liên hệ'
required
style={{ width: '100%', padding: '10px', boxSizing: 'border-box' }}
/>
</div>
{/* Message */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='message'>
Nội dung{' '}
<span
style={{ color: 'red' }}
aria-label='bắt buộc'
>
*
</span>
</label>
<textarea
id='message'
name='message'
aria-required='true'
rows={6}
placeholder='Mô tả chi tiết yêu cầu của bạn (ít nhất 20 ký tự)'
required
style={{ width: '100%', padding: '10px', boxSizing: 'border-box' }}
/>
</div>
{/* File */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='attachment'>Đính kèm tài liệu (tùy chọn)</label>
<input
type='file'
id='attachment'
name='attachment'
accept='.pdf,.doc,.docx'
onChange={handleFileChange}
style={{ width: '100%', padding: '10px', boxSizing: 'border-box' }}
/>
</div>
{/* Privacy */}
<div style={{ marginBottom: '28px' }}>
<label>
<input
type='checkbox'
id='privacy'
name='privacy'
aria-required='true'
required
/>{' '}
Tôi đã đọc và đồng ý với{' '}
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
>
Chính sách bảo mật
</a>
<span
style={{ color: 'red' }}
aria-label='bắt buộc'
>
*
</span>
</label>
</div>
{/* Submit */}
<button
type='submit'
disabled={isSubmitting}
style={{
backgroundColor: isSubmitting ? '#6c757d' : '#0d6efd',
color: 'white',
padding: '12px 32px',
border: 'none',
borderRadius: '6px',
fontSize: '16px',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
minWidth: '160px',
}}
>
{isSubmitting ? 'Đang gửi...' : 'Gửi thông tin'}
</button>
{/* Inline CSS */}
<style>{`
.field-error {
color: #dc3545;
font-size: 14px;
margin-top: 6px;
display: none;
}
.error-summary {
background: #f8d7da;
border-left: 5px solid #dc3545;
padding: 16px 20px;
margin-bottom: 28px;
border-radius: 4px;
display: none;
}
.error-summary h3 {
margin: 0 0 12px 0;
color: #721c24;
}
.error-summary ul {
margin: 0;
padding-left: 24px;
}
[aria-invalid="true"] {
border: 2px solid #dc3545 !important;
}
input:focus, select:focus, textarea:focus {
outline: 3px solid #0d6efd;
outline-offset: 2px;
}
@media (prefers-contrast: high) {
input, select, textarea, button {
border: 2px solid currentColor !important;
}
}
@media (max-width: 600px) {
form { padding: 16px; }
button { width: 100%; }
}
`}</style>
</form>
);
}
/* Ví dụ kết quả console khi submit thành công:
✅ Contact form submitted: {
name: "Trần Thị B",
email: "b.tran@company.vn",
phone: "0918123456",
department: "support",
priority: "high",
subject: "Hỗ trợ kỹ thuật khẩn cấp",
message: "Máy chủ bị lỗi 502 liên tục từ 14h hôm nay...",
privacy: "on",
file: { name: "error-log.pdf", size: "2.34 MB", type: "application/pdf" }
}
*/📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: Controlled vs Uncontrolled Forms
| Aspect | Uncontrolled Form | Controlled Form (Preview) |
|---|---|---|
| Value Source | DOM (HTML elements) | React state |
| Access Method | FormData, event.target.elements | State variables |
| Update Trigger | User types directly to DOM | User types → Event → State update → Re-render |
| Validation | On submit hoặc onBlur | Realtime (mỗi keystroke) |
| Code Complexity | ✅ Đơn giản (ít code) | ⚠️ Phức tạp hơn (cần state) |
| Re-renders | ✅ Ít (chỉ khi submit) | ⚠️ Nhiều (mỗi keystroke) |
| Dynamic UI | ❌ Khó (cần DOM manipulation) | ✅ Dễ (dựa vào state) |
| Default Values | defaultValue | value={state} |
| Reset Form | form.reset() | Set state về initial |
| Format Input | ❌ Khó (phải dùng onInput + DOM) | ✅ Dễ (format state) |
| Conditional Fields | ⚠️ CSS show/hide | ✅ Conditional rendering |
| Cross-field Validation | ✅ Dễ (có tất cả giá trị) | ✅ Dễ (có tất cả state) |
| Performance | ✅ Tốt hơn (ít renders) | ⚠️ Có thể chậm (nhiều renders) |
| Testing | ⚠️ Khó (cần query DOM) | ✅ Dễ (check state) |
When to use Uncontrolled:
- ✅ Simple forms (1-5 fields)
- ✅ Chỉ cần data khi submit
- ✅ Không cần realtime validation
- ✅ Performance critical
- ✅ Third-party form libraries
When to use Controlled: (Sẽ học Ngày 11)
- ✅ Realtime validation
- ✅ Format input while typing
- ✅ Conditional form logic
- ✅ Dynamic form fields
- ✅ Complex UX requirements
Decision Tree: Chọn Form Pattern
START: Bạn cần build form?
│
├─> Form có <3 fields?
│ ├─> YES ──> Chỉ cần data khi submit?
│ │ ├─> YES ──> ✅ UNCONTROLLED (Simple)
│ │ └─> NO ───> ⚠️ Cần realtime feedback?
│ │ ├─> YES ──> 🔜 CONTROLLED (Ngày 11)
│ │ └─> NO ───> ✅ UNCONTROLLED
│ └─> NO
│
├─> Cần validate realtime (mỗi keystroke)?
│ ├─> YES ──> 🔜 CONTROLLED (Ngày 11)
│ └─> NO
│
├─> Cần format input while typing?
│ │ (Ví dụ: credit card 1234-5678-9012-3456)
│ ├─> YES ──> 🔜 CONTROLLED (Ngày 11)
│ └─> NO
│
├─> Cần disable button dựa vào input values?
│ ├─> YES ──> 🔜 CONTROLLED (Ngày 11)
│ └─> NO
│
├─> Form fields thay đổi động?
│ │ (Ví dụ: Add/remove fields)
│ ├─> YES ──> 🔜 CONTROLLED (Ngày 11)
│ └─> NO
│
└─> Default: ✅ UNCONTROLLED
(Simple, performant, đủ cho most cases)Pattern Combinations
// ✅ Hybrid Pattern: Uncontrolled + Manual Validation
function HybridForm() {
const handleSubmit = (event) => {
event.preventDefault();
// Uncontrolled: Lấy data từ DOM
const data = new FormData(event.target);
// Manual validation
if (validate(data)) {
submitToAPI(data);
}
};
return <form onSubmit={handleSubmit}>...</form>;
}
// 🔜 Controlled Pattern (Ngày 11)
function ControlledForm() {
const [email, setEmail] = useState('');
const [isValid, setIsValid] = useState(false);
const handleChange = (e) => {
const value = e.target.value;
setEmail(value);
setIsValid(validateEmail(value));
};
return (
<form>
<input
value={email}
onChange={handleChange}
/>
{!isValid && <span>Invalid email</span>}
</form>
);
}🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Form Submit Reloads Page ⚠️
// ❌ BUG: Page reloads sau khi submit
function BuggyForm1() {
const handleSubmit = () => {
// ❌ Thiếu event.preventDefault()!
const data = new FormData(document.getElementById('myform'));
console.log(data);
};
return (
<form
id='myform'
onSubmit={handleSubmit}
>
<input name='email' />
<button type='submit'>Submit</button>
</form>
);
}
// 🤔 QUESTIONS:
// 1. Tại sao page reload?
// 2. Console.log có chạy không?
// 3. Làm sao fix?
// ✅ SOLUTION:
function FixedForm1() {
const handleSubmit = (event) => {
event.preventDefault(); // ← KEY FIX!
const data = new FormData(event.target);
console.log(Object.fromEntries(data));
};
return (
<form onSubmit={handleSubmit}>
<input name='email' />
<button type='submit'>Submit</button>
</form>
);
}
// 📚 EXPLANATION:
// - HTML form mặc định submit đến server (page reload)
// - event.preventDefault() ngăn behavior mặc định
// - Luôn LUÔN cần preventDefault trong React forms!Bug 2: Cannot Read Checkbox Value ⚠️
// ❌ BUG: Checkbox value luôn là null
function BuggyForm2() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const terms = formData.get('terms');
console.log('Terms:', terms); // ❌ null hoặc "on"?
if (terms === true) {
// ❌ SAI! Không bao giờ true
console.log('Accepted');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type='checkbox'
name='terms'
/>
<button type='submit'>Submit</button>
</form>
);
}
// 🤔 QUESTIONS:
// 1. Checkbox value là gì khi checked/unchecked?
// 2. Làm sao convert sang boolean?
// 3. Tại sao không dùng === true?
// ✅ SOLUTION:
function FixedForm2() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const termsValue = formData.get('terms');
// termsValue is "on" (checked) or null (unchecked)
const termsAccepted = !!termsValue; // Convert to boolean
// OR: termsValue !== null
// OR: formData.has('terms')
console.log('Terms accepted:', termsAccepted);
if (termsAccepted) {
console.log('✅ User accepted terms');
} else {
console.log('❌ User did NOT accept terms');
}
};
return (
<form onSubmit={handleSubmit}>
<label>
<input
type='checkbox'
name='terms'
/>{' '}
I accept terms
</label>
<button type='submit'>Submit</button>
</form>
);
}
// 📚 EXPLANATION:
// - Unchecked checkbox: formData.get() returns null
// - Checked checkbox: formData.get() returns "on" (string!)
// - Convert to boolean: !!value or value !== null
// - formData.has('key') is cleaner for existence checkBug 3: Cannot Get Multiple Checkbox Values ⚠️
// ❌ BUG: Chỉ lấy được 1 checkbox value
function BuggyForm3() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
// ❌ SAI: get() chỉ trả về GIÁ TRỊ ĐẦU TIÊN!
const interests = formData.get('interests');
console.log('Interests:', interests); // Chỉ 1 value!
};
return (
<form onSubmit={handleSubmit}>
<label>
<input
type='checkbox'
name='interests'
value='sports'
/>{' '}
Sports
</label>
<label>
<input
type='checkbox'
name='interests'
value='music'
/>{' '}
Music
</label>
<label>
<input
type='checkbox'
name='interests'
value='tech'
/>{' '}
Tech
</label>
<button type='submit'>Submit</button>
</form>
);
}
// 🤔 QUESTIONS:
// 1. Tại sao chỉ lấy được 1 value?
// 2. Làm sao lấy tất cả checked values?
// 3. Nếu không có checkbox nào checked thì sao?
// ✅ SOLUTION:
function FixedForm3() {
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
// ✅ ĐÚNG: Dùng getAll() cho multiple values
const interests = formData.getAll('interests');
console.log('Interests:', interests); // Array of values
if (interests.length === 0) {
console.log('No interests selected');
} else {
console.log('Selected:', interests.join(', '));
}
};
return (
<form onSubmit={handleSubmit}>
<fieldset>
<legend>Select your interests:</legend>
<label style={{ display: 'block' }}>
<input
type='checkbox'
name='interests'
value='sports'
/>{' '}
Sports
</label>
<label style={{ display: 'block' }}>
<input
type='checkbox'
name='interests'
value='music'
/>{' '}
Music
</label>
<label style={{ display: 'block' }}>
<input
type='checkbox'
name='interests'
value='tech'
/>{' '}
Tech
</label>
</fieldset>
<button type='submit'>Submit</button>
</form>
);
}
// 📚 EXPLANATION:
// - get(name): Returns first value only
// - getAll(name): Returns ARRAY of all values
// - Unchecked checkboxes: NOT included in array
// - Always use getAll() for multiple checkboxes with same name✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
Đánh dấu ✅ những điều bạn đã hiểu:
Form Basics:
- [ ] Hiểu sự khác biệt giữa HTML form và React form
- [ ] Biết khi nào cần
event.preventDefault() - [ ]Biết cách truy xuất form values bằng
FormDataAPI - [ ] Biết cách truy xuất form values bằng
event.target.elements - [ ] Hiểu
nameattribute quan trọng như thế nào
Input Types:
- [ ] Xử lý được text, email, password inputs
- [ ] Xử lý được checkboxes (single & multiple)
- [ ] Xử lý được radio buttons
- [ ] Xử lý được select dropdowns
- [ ] Xử lý được textarea
- [ ] Xử lý được file uploads
Validation:
- [ ] Validate required fields
- [ ] Validate email format
- [ ] Validate string length (min/max)
- [ ] Cross-field validation (password confirm)
- [ ] Regex validation
- [ ] File validation (size, type)
Event Handling:
- [ ] Hiểu sự khác biệt
onChangeReact vs HTML - [ ] Dùng được
onSubmit,onChange,onBlur - [ ] Prevent paste (
onPaste) - [ ] Format input (
onInput)
Best Practices:
- [ ] Accessibility (labels, aria attributes)
- [ ] Error handling (inline + summary)
- [ ] Loading states
- [ ] Security (input sanitization)
- [ ] Form reset
Uncontrolled vs Controlled (Conceptual):
- [ ] Hiểu khái niệm controlled component
- [ ] Biết limitations của uncontrolled forms
- [ ] Biết khi nào cần controlled (prepare for Ngày 11)
Code Review Checklist
Review code của bạn:
Functionality:
- [ ] Form submit không reload page?
- [ ] Validation chạy đúng?
- [ ] Error messages hiển thị rõ ràng?
- [ ] Form reset sau submit thành công?
Code Quality:
- [ ] Event handlers có
event.preventDefault()? - [ ] Dùng
FormData.get()cho single value? - [ ] Dùng
FormData.getAll()cho multiple values? - [ ] Convert checkbox value sang boolean?
- [ ] Trim whitespace từ text inputs?
Accessibility:
- [ ] All inputs có labels?
- [ ] Labels có
htmlForattribute? - [ ] Required fields marked (aria-required)?
- [ ] Error messages có role="alert"?
User Experience:
- [ ] Placeholder text hữu ích?
- [ ] Error messages dễ hiểu?
- [ ] Submit button có loading state?
- [ ] Focus management hợp lý?
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút): Event Registration Form
Tạo form đăng ký sự kiện với:
- Event name (select dropdown: Workshop, Conference, Meetup)
- Attendee name (text, required, min 3 chars)
- Email (email, required, valid format)
- Number of tickets (number, 1-10)
- Dietary restrictions (checkboxes: Vegetarian, Vegan, Gluten-free, None)
- Special requests (textarea, optional, max 300 chars)
Requirements:
- Validate all fields
- Calculate total price (ticket price varies by event type)
- Display summary before submit
- Console.log final data
💡 Solution
/**
* Event Registration Form - Uncontrolled pattern
* - Validate tất cả fields khi submit
* - Tính tổng giá tiền dựa trên loại sự kiện
* - Hiển thị summary trước khi submit thật (confirm dialog)
* - Console.log dữ liệu cuối cùng khi hoàn tất
*/
function EventRegistrationForm() {
const TICKET_PRICES = {
workshop: 300000, // 300.000 VNĐ
conference: 1200000, // 1.200.000 VNĐ
meetup: 150000, // 150.000 VNĐ
};
const handleSubmit = (event) => {
event.preventDefault();
const formData = new FormData(event.target);
// Lấy dữ liệu
const data = {
eventType: formData.get('eventType'),
attendeeName: (formData.get('attendeeName') || '').trim(),
email: (formData.get('email') || '').trim(),
tickets: parseInt(formData.get('tickets') || '0', 10),
restrictions: formData.getAll('restrictions'),
requests: (formData.get('requests') || '').trim(),
};
// Validation
const errors = [];
// Event type
if (!data.eventType) {
errors.push('Vui lòng chọn loại sự kiện');
}
// Attendee name
if (!data.attendeeName || data.attendeeName.length < 3) {
errors.push('Tên người tham dự phải ít nhất 3 ký tự');
}
// Email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!data.email) {
errors.push('Email không được để trống');
} else if (!emailRegex.test(data.email)) {
errors.push('Email không hợp lệ');
}
// Number of tickets
if (isNaN(data.tickets) || data.tickets < 1 || data.tickets > 10) {
errors.push('Số lượng vé phải từ 1 đến 10');
}
// Dietary restrictions - không bắt buộc, nhưng nếu chọn "None" thì không nên chọn cái khác
if (data.restrictions.includes('none') && data.restrictions.length > 1) {
errors.push(
'Nếu chọn "Không có hạn chế" thì không nên chọn hạn chế khác',
);
}
// Special requests - optional, chỉ check độ dài
if (data.requests.length > 300) {
errors.push('Yêu cầu đặc biệt không được vượt quá 300 ký tự');
}
if (errors.length > 0) {
alert('Vui lòng sửa các lỗi sau:\n\n' + errors.join('\n'));
return;
}
// Tính giá
const ticketPrice = TICKET_PRICES[data.eventType];
const totalPrice = ticketPrice * data.tickets;
// Tạo summary để confirm
const restrictionsText =
data.restrictions.length === 0
? 'Không'
: data.restrictions
.map((r) => {
switch (r) {
case 'vegetarian':
return 'Chay';
case 'vegan':
return 'Thuần chay';
case 'glutenfree':
return 'Không gluten';
case 'none':
return 'Không có hạn chế';
default:
return r;
}
})
.join(', ');
const summary = `
ĐĂNG KÝ SỰ KIỆN - XÁC NHẬN
Loại sự kiện: ${
data.eventType === 'workshop'
? 'Workshop'
: data.eventType === 'conference'
? 'Hội nghị'
: 'Meetup'
}
Tên người tham dự: ${data.attendeeName}
Email: ${data.email}
Số lượng vé: ${data.tickets}
Giá mỗi vé: ${ticketPrice.toLocaleString('vi-VN')} VNĐ
Tổng tiền: ${totalPrice.toLocaleString('vi-VN')} VNĐ
Hạn chế ăn uống: ${restrictionsText}
Yêu cầu đặc biệt:
${data.requests || 'Không có'}
Bạn có muốn gửi đăng ký này không?
`;
if (!confirm(summary)) {
return; // User hủy
}
// Submit thành công
const finalData = {
...data,
totalPrice,
ticketPrice,
submittedAt: new Date().toISOString(),
};
console.log('✅ Đăng ký sự kiện thành công:', finalData);
alert('Đăng ký thành công! Chúng tôi đã nhận được thông tin của bạn.');
event.target.reset();
};
return (
<form
onSubmit={handleSubmit}
style={{ maxWidth: '540px', margin: '0 auto' }}
>
<h2>Đăng ký tham dự sự kiện</h2>
{/* Event Type */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='eventType'>Loại sự kiện: *</label>
<select
id='eventType'
name='eventType'
required
style={{ width: '100%', padding: '8px' }}
>
<option
value=''
disabled
selected
>
-- Chọn loại sự kiện --
</option>
<option value='workshop'>Workshop</option>
<option value='conference'>Conference</option>
<option value='meetup'>Meetup</option>
</select>
</div>
{/* Attendee Name */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='attendeeName'>Họ và tên: *</label>
<input
type='text'
id='attendeeName'
name='attendeeName'
required
minLength={3}
placeholder='Ví dụ: Nguyễn Văn A'
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Email */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='email'>Email: *</label>
<input
type='email'
id='email'
name='email'
required
placeholder='example@email.com'
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Number of Tickets */}
<div style={{ marginBottom: '20px' }}>
<label htmlFor='tickets'>Số lượng vé (1-10): *</label>
<input
type='number'
id='tickets'
name='tickets'
min='1'
max='10'
defaultValue='1'
required
style={{ width: '100%', padding: '8px' }}
/>
</div>
{/* Dietary Restrictions */}
<fieldset style={{ marginBottom: '20px' }}>
<legend>Hạn chế về ăn uống:</legend>
<label style={{ display: 'block', margin: '6px 0' }}>
<input
type='checkbox'
name='restrictions'
value='vegetarian'
/>
Chay (Vegetarian)
</label>
<label style={{ display: 'block', margin: '6px 0' }}>
<input
type='checkbox'
name='restrictions'
value='vegan'
/>
Thuần chay (Vegan)
</label>
<label style={{ display: 'block', margin: '6px 0' }}>
<input
type='checkbox'
name='restrictions'
value='glutenfree'
/>
Không gluten (Gluten-free)
</label>
<label style={{ display: 'block', margin: '6px 0' }}>
<input
type='checkbox'
name='restrictions'
value='none'
/>
Không có hạn chế nào
</label>
</fieldset>
{/* Special Requests */}
<div style={{ marginBottom: '24px' }}>
<label htmlFor='requests'>Yêu cầu đặc biệt (tùy chọn):</label>
<textarea
id='requests'
name='requests'
rows={4}
maxLength={300}
placeholder='Ví dụ: cần chỗ ngồi gần sân khấu, dị ứng hải sản...'
style={{ width: '100%', padding: '8px' }}
/>
</div>
<button
type='submit'
style={{
backgroundColor: '#28a745',
color: 'white',
padding: '12px 28px',
border: 'none',
borderRadius: '6px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Đăng ký ngay
</button>
</form>
);
}
/* Ví dụ kết quả console khi submit thành công:
✅ Đăng ký sự kiện thành công: {
eventType: "conference",
attendeeName: "Trần Thị Bích",
email: "bich.tran@example.com",
tickets: 2,
restrictions: ["vegetarian", "glutenfree"],
requests: "Cần chỗ ngồi gần loa, không uống cà phê",
totalPrice: 2400000,
ticketPrice: 1200000,
submittedAt: "2026-01-21T08:15:32.123Z"
}
*/Nâng cao (60 phút): Job Application Form
Build form ứng tuyển công việc:
- Personal info (name, email, phone)
- Position applying (dropdown)
- Experience level (radio: Junior/Mid/Senior)
- Resume upload (PDF only, max 2MB)
- Cover letter (textarea, 50-500 words)
- References (checkbox: "I have 2+ references")
- Portfolio URL (optional, must be valid URL if provided)
Advanced requirements:
- Word count for cover letter
- URL validation for portfolio
- Conditional field: If Senior → "Years of experience" (number input)
- Multi-step: Step 1 (Personal), Step 2 (Application), Step 3 (Review & Submit)
- Accessibility: Full ARIA support
- Error handling: Inline + summary
💡 Solution
/**
* Job Application Form - Production-ready, uncontrolled, multi-step
* Với conditional fields, validation đầy đủ, accessibility ARIA,
* error inline + summary, file validation, word count display
*/
function JobApplicationForm() {
// Track submission state (non-React)
let isSubmitting = false;
// Ticket prices or other constants if needed - not here
// Show specific step
const showStep = (stepNumber) => {
document.querySelectorAll('.form-step').forEach((step) => {
step.style.display = 'none';
});
const target = document.getElementById(`step-${stepNumber}`);
if (target) target.style.display = 'block';
const indicator = document.getElementById('step-indicator');
if (indicator) indicator.textContent = `Bước ${stepNumber}/3`;
};
// Show/hide conditional field based on experience level
const handleExperienceChange = (event) => {
const yearsSection = document.getElementById('years-experience-section');
if (yearsSection) {
yearsSection.style.display =
event.target.value === 'senior' ? 'block' : 'none';
if (event.target.value !== 'senior') {
const input = document.getElementById('yearsExperience');
if (input) input.value = '';
}
}
};
// Update word count for cover letter
const handleCoverLetterInput = (event) => {
const text = event.target.value.trim();
const words = text ? text.split(/\s+/).length : 0;
const countEl = document.getElementById('cover-letter-word-count');
if (countEl) {
countEl.textContent = `Số từ: ${words} (50-500 từ)`;
countEl.style.color = words < 50 || words > 500 ? '#dc3545' : '#198754';
}
};
// Validate file resume
const handleResumeChange = (event) => {
const file = event.target.files[0];
const errorEl = document.getElementById('resume-error');
if (errorEl) errorEl.style.display = 'none';
if (!file) return;
const maxSize = 2 * 1024 * 1024; // 2MB
if (file.size > maxSize) {
showFieldError(
'resume',
`File quá lớn (tối đa 2MB). Kích thước: ${(file.size / 1024 / 1024).toFixed(2)}MB`,
);
event.target.value = '';
return;
}
if (file.type !== 'application/pdf') {
showFieldError('resume', 'Chỉ chấp nhận file PDF');
event.target.value = '';
return;
}
};
// Validate step 1: Personal info
const validateStep1 = () => {
const name = document.getElementById('name')?.value.trim() || '';
const email = document.getElementById('email')?.value.trim() || '';
const phone = document.getElementById('phone')?.value.trim() || '';
const errors = [];
if (name.length < 3) errors.push('Họ tên phải ít nhất 3 ký tự');
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errors.push('Email không hợp lệ');
if (phone && !/^[0-9]{10,11}$/.test(phone))
errors.push('Số điện thoại phải 10-11 chữ số');
return errors;
};
// Validate step 2: Application info
const validateStep2 = () => {
const position = document.getElementById('position')?.value || '';
const experience =
document.querySelector('input[name="experience"]:checked')?.value || '';
const years = document.getElementById('yearsExperience')?.value || '';
const portfolio = document.getElementById('portfolio')?.value.trim() || '';
const errors = [];
if (!position) errors.push('Vui lòng chọn vị trí ứng tuyển');
if (!experience) errors.push('Vui lòng chọn mức độ kinh nghiệm');
if (experience === 'senior' && (isNaN(years) || years < 5 || years > 50)) {
errors.push('Số năm kinh nghiệm phải từ 5 đến 50');
}
if (portfolio && !/^https?:\/\/[^\s$.?#].[^\s]*$/.test(portfolio)) {
errors.push('Portfolio URL không hợp lệ (phải bắt đầu bằng http/https)');
}
return errors;
};
// Next handlers with validation
const handleNext1 = () => {
clearErrors();
const errors = validateStep1();
if (errors.length > 0) {
showErrorSummary(errors);
return;
}
showStep(2);
};
const handleNext2 = () => {
clearErrors();
const errors = validateStep2();
if (errors.length > 0) {
showErrorSummary(errors);
return;
}
// Generate review summary in step 3
generateReviewSummary();
showStep(3);
};
// Back handlers
const handleBack2 = () => showStep(1);
const handleBack3 = () => showStep(2);
// Generate summary for step 3
const generateReviewSummary = () => {
const form = document.querySelector('form');
if (!form) return;
const formData = new FormData(form);
const data = {
name: formData.get('name')?.trim(),
email: formData.get('email')?.trim(),
phone: formData.get('phone')?.trim(),
position: formData.get('position'),
experience: formData.get('experience'),
yearsExperience: formData.get('yearsExperience'),
portfolio: formData.get('portfolio')?.trim(),
coverLetter: formData.get('coverLetter')?.trim(),
references: !!formData.get('references'),
resume: document.getElementById('resume')?.files[0],
};
const positionMap = {
developer: 'Developer',
designer: 'Designer',
manager: 'Manager',
};
const experienceMap = {
junior: 'Junior',
mid: 'Mid-level',
senior: 'Senior',
};
const summaryEl = document.getElementById('review-summary');
if (summaryEl) {
summaryEl.innerHTML = `
<h4>Thông tin cá nhân</h4>
<p>Họ tên: ${sanitizeInput(data.name || 'N/A')}</p>
<p>Email: ${sanitizeInput(data.email || 'N/A')}</p>
<p>Số điện thoại: ${sanitizeInput(data.phone || 'N/A')}</p>
<h4>Thông tin ứng tuyển</h4>
<p>Vị trí: ${positionMap[data.position] || 'N/A'}</p>
<p>Mức độ kinh nghiệm: ${experienceMap[data.experience] || 'N/A'}</p>
${data.experience === 'senior' ? `<p>Số năm kinh nghiệm: ${data.yearsExperience || 'N/A'}</p>` : ''}
<p>Portfolio URL: ${sanitizeInput(data.portfolio || 'Không có')}</p>
<h4>Tài liệu</h4>
<p>Resume: ${data.resume ? sanitizeInput(data.resume.name) : 'Chưa upload'}</p>
<p>Thư xin việc: ${data.coverLetter ? `${data.coverLetter.substring(0, 100)}... (${data.coverLetter.split(/\s+/).length} từ)` : 'Không có'}</p>
<p>Tham chiếu: ${data.references ? 'Có (2+ references)' : 'Không'}</p>
`;
}
};
// Full validation for submit
const validateAll = (formData, resumeInput) => {
const errors = [...validateStep1(), ...validateStep2()];
const data = {
coverLetter: formData.get('coverLetter')?.trim() || '',
references: !!formData.get('references'),
resume: resumeInput.files[0],
};
const wordCount = data.coverLetter.split(/\s+/).filter((w) => w).length;
if (wordCount < 50 || wordCount > 500) {
errors.push(`Thư xin việc phải từ 50-500 từ (hiện tại: ${wordCount})`);
}
if (data.resume) {
if (data.resume.type !== 'application/pdf')
errors.push('Resume phải là file PDF');
if (data.resume.size > 2 * 1024 * 1024) errors.push('Resume tối đa 2MB');
} else {
errors.push('Vui lòng upload resume');
}
// Show inline errors where possible
if (errors.find((e) => e.includes('Thư xin việc')))
showFieldError('coverLetter', 'Thư xin việc phải 50-500 từ');
if (errors.find((e) => e.includes('Resume')))
showFieldError('resume', 'Resume phải PDF, <2MB');
return {
valid: errors.length === 0,
errors,
data: { ...data /* add others if needed */ },
};
};
// Submit handler
const handleSubmit = async (event) => {
event.preventDefault();
if (isSubmitting) return;
clearErrors();
const form = event.target;
const formData = new FormData(form);
const resumeInput = document.getElementById('resume');
const { valid, errors } = validateAll(formData, resumeInput);
if (!valid) {
showErrorSummary(errors);
const firstInvalid = document.querySelector('[aria-invalid="true"]');
if (firstInvalid) firstInvalid.focus();
return;
}
isSubmitting = true;
const submitBtn = form.querySelector('button[type="submit"]');
const origText = submitBtn.textContent;
submitBtn.textContent = 'Đang gửi...';
submitBtn.disabled = true;
try {
// Simulate API
await new Promise((r) => setTimeout(r, 1500));
console.log('✅ Ứng tuyển thành công:', Object.fromEntries(formData));
alert('Đăng ký ứng tuyển thành công!');
form.reset();
showStep(1);
} catch (e) {
alert('Lỗi: ' + e.message);
} finally {
isSubmitting = false;
submitBtn.textContent = origText;
submitBtn.disabled = false;
}
};
// Helper functions from previous (sanitizeInput, showFieldError, clearErrors, showErrorSummary)
/**
* Sanitize to prevent XSS
*/
function sanitizeInput(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Show field error (similar to prev)
const showFieldError = (fieldId, message) => {
const errorId = `${fieldId}-error`;
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement('span');
errorEl.id = errorId;
errorEl.className = 'error-message';
errorEl.setAttribute('role', 'alert');
const field = document.getElementById(fieldId);
if (field) field.parentNode.appendChild(errorEl);
}
errorEl.textContent = message;
errorEl.style.display = 'block';
const field = document.getElementById(fieldId);
if (field) field.setAttribute('aria-invalid', 'true');
};
// Clear errors (similar)
const clearErrors = () => {
document
.querySelectorAll('.error-message')
.forEach((el) => (el.style.display = 'none'));
document
.querySelectorAll('[aria-invalid="true"]')
.forEach((el) => el.removeAttribute('aria-invalid'));
const summary = document.getElementById('error-summary');
if (summary) summary.style.display = 'none';
};
// Show summary (similar)
const showErrorSummary = (errors) => {
let summary = document.getElementById('error-summary');
if (!summary) {
summary = document.createElement('div');
summary.id = 'error-summary';
summary.setAttribute('role', 'alert');
const form = document.querySelector('form');
form.insertBefore(summary, form.firstChild);
}
summary.innerHTML =
'<h3>Lỗi:</h3><ul>' +
errors.map((e) => `<li>${e}</li>`).join('') +
'</ul>';
summary.style.display = 'block';
summary.focus();
};
// Initial setup
setTimeout(() => showStep(1), 0);
return (
<form onSubmit={handleSubmit}>
<div
id='step-indicator'
style={{ fontWeight: 'bold' }}
>
Bước 1/3
</div>
<div
id='error-summary'
style={{ display: 'none', color: 'red' }}
></div>
{/* Step 1: Personal */}
<div
id='step-1'
className='form-step'
>
<h3>Thông tin cá nhân</h3>
<label htmlFor='name'>Họ tên *</label>
<input
id='name'
name='name'
aria-required='true'
/>
<label htmlFor='email'>Email *</label>
<input
id='email'
name='email'
type='email'
aria-required='true'
/>
<label htmlFor='phone'>Số điện thoại</label>
<input
id='phone'
name='phone'
type='tel'
/>
<button
type='button'
onClick={handleNext1}
>
Tiếp theo
</button>
</div>
{/* Step 2: Application */}
<div
id='step-2'
className='form-step'
style={{ display: 'none' }}
>
<h3>Thông tin ứng tuyển</h3>
<label htmlFor='position'>Vị trí ứng tuyển *</label>
<select
id='position'
name='position'
aria-required='true'
>
<option value=''>Chọn vị trí</option>
<option value='developer'>Developer</option>
<option value='designer'>Designer</option>
<option value='manager'>Manager</option>
</select>
<fieldset aria-required='true'>
<legend>Mức độ kinh nghiệm *</legend>
<label>
<input
type='radio'
name='experience'
value='junior'
onChange={handleExperienceChange}
/>{' '}
Junior
</label>
<label>
<input
type='radio'
name='experience'
value='mid'
onChange={handleExperienceChange}
/>{' '}
Mid
</label>
<label>
<input
type='radio'
name='experience'
value='senior'
onChange={handleExperienceChange}
/>{' '}
Senior
</label>
</fieldset>
<div
id='years-experience-section'
style={{ display: 'none' }}
>
<label htmlFor='yearsExperience'>Số năm kinh nghiệm *</label>
<input
id='yearsExperience'
name='yearsExperience'
type='number'
min='5'
max='50'
aria-required='true'
/>
</div>
<label htmlFor='portfolio'>Portfolio URL (tùy chọn)</label>
<input
id='portfolio'
name='portfolio'
type='url'
/>
<button
type='button'
onClick={handleBack2}
>
Quay lại
</button>
<button
type='button'
onClick={handleNext2}
>
Tiếp theo
</button>
</div>
{/* Step 3: Review & Submit */}
<div
id='step-3'
className='form-step'
style={{ display: 'none' }}
>
<h3>Xem lại thông tin</h3>
<div id='review-summary'></div>
<button
type='button'
onClick={handleBack3}
>
Quay lại
</button>
<button type='submit'>Gửi ứng tuyển</button>
</div>
{/* Global styles */}
<style>{`
.form-step { margin: 20px 0; }
label { display: block; margin-top: 10px; }
input, select, textarea { width: 100%; margin-bottom: 10px; }
.error-message { color: red; display: none; font-size: 0.9em; }
`}</style>
</form>
);
}
/* Ví dụ console khi submit thành công:
✅ Ứng tuyển thành công: {
name: "Nguyễn Văn A",
email: "a@example.com",
phone: "0123456789",
position: "developer",
experience: "senior",
yearsExperience: "10",
portfolio: "https://portfolio.com",
resume: File { name: "resume.pdf", ... },
coverLetter: "Long text here...",
references: "on"
}
*/📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - Forms
- https://react.dev/reference/react-dom/components/form
- Đọc: Form elements, Controlled vs Uncontrolled
MDN - FormData API
- https://developer.mozilla.org/en-US/docs/Web/API/FormData
- Đọc: get(), getAll(), has() methods
Đọc thêm
Web.dev - Form Best Practices
- https://web.dev/sign-in-form-best-practices/
- Accessibility, UX, Security
WCAG 2.1 - Forms
- https://www.w3.org/WAI/tutorials/forms/
- Accessibility guidelines
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (Đã học)
- Ngày 1-2: ES6+ (destructuring, spread operator)
- Ngày 3: JSX syntax
- Ngày 4: Props & Components
- Ngày 5: Event handling, Conditional rendering
- Ngày 6: Lists & Keys
- Ngày 7: Component composition
Hướng tới (Sẽ học)
- Ngày 11: useState - Controlled Components
- Ngày 12: useReducer - Complex form state
- Ngày 36-38: Advanced Form Management (React Hook Form, Formik)
- Ngày 42: Form Testing
- Ngày 44: Accessibility Testing
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Form Validation Strategies:
// ❌ Amateur: Validate chỉ khi submit
// ⚠️ Mid: Validate onBlur
// ✅ Senior: Progressive validation
// - onChange: Format (e.g., phone number)
// - onBlur: Validate & show error
// - onSubmit: Final check2. Error Handling:
// ❌ Amateur: alert() cho errors
// ⚠️ Mid: Inline errors
// ✅ Senior: Inline + Summary + Focus management + Screen reader3. Performance:
// Uncontrolled form: ~0 re-renders (best)
// Controlled with debounce: ~10 re-renders (good)
// Controlled without debounce: ~100 re-renders (avoid!)4. Security:
// ALWAYS sanitize trước khi:
// - Display to DOM (XSS prevention)
// - Send to server (SQL injection prevention)
// - Store to localStorage (XSS prevention)Câu Hỏi Phỏng Vấn
Junior Level:
Q: Sự khác biệt giữa controlled và uncontrolled components?
A: Controlled: React quản lý value qua state. Uncontrolled: DOM quản lý value, React đọc khi cần.
Q:
event.preventDefault()làm gì?A: Ngăn form submit mặc định (page reload).
Q: Làm sao lấy giá trị từ multiple checkboxes?
A:
formData.getAll('name')returns array.
Mid Level:
- Q: Khi nào dùng controlled vs uncontrolled?
A: Controlled khi cần realtime validation/formatting. Uncontrolled cho simple forms.
Q: Làm sao validate file upload?
A: Check
file.size(max size) vàfile.type(MIME type).Q: Accessibility concerns cho forms?
A: Labels, aria-required, aria-invalid, error announcements, keyboard navigation.
Senior Level:
- Q: Design multi-step form architecture
A: Options: Hidden sections, URL-based, Wizard component. Consider: State persistence, validation per step, back button UX.
Q: Optimize form performance
A: Uncontrolled for simple forms, debounce for controlled, memo for complex validation logic.
Q: Security trong form handling
A: Sanitize inputs, CSRF tokens, rate limiting, file upload restrictions, HTTPS only.
War Stories
Story 1: The Million-Dollar Typo
Dự án: E-commerce checkout form
Vấn đề: User gõ email sai → Không nhận order confirmation
Giải pháp:
- Typo detection (did you mean?)
- Email confirmation field
- Preview trước submit
Lesson: UX > Validation rulesStory 2: The Invisible Error
Dự án: Job application form
Vấn đề: Screen reader users không nghe thấy errors
Giải pháp:
- role="alert" cho errors
- aria-describedby link input → error
- Focus management
Lesson: Accessibility = Legal requirementStory 3: The Performance Nightmare
Dự án: Survey với 50+ fields
Vấn đề: Controlled form re-renders 1000+ times/second
Giải pháp:
- Switch to uncontrolled
- Validate onBlur instead of onChange
- Debounce expensive validations
Lesson: Choose right tool for the job🎯 PREVIEW NGÀY MAI
Ngày 10: ⚡ Mini Project 1 - Static Product Catalog
Ngày mai chúng ta sẽ tổng hợp TOÀN BỘ kiến thức từ Ngày 1-9:
- Component hierarchy (Ngày 4, 7)
- Props drilling (Ngày 4)
- Lists & Keys (Ngày 6)
- Events (Ngày 5)
- Forms (Ngày 9) ← HÔM NAY!
- Conditional rendering (Ngày 5)
- Styling (Ngày 8)
Project: Build một product catalog với search, filter, và contact form!
Chuẩn bị:
- Review lại concepts Ngày 1-9
- Đảm bảo dev environment sẵn sàng
- Nghỉ ngơi đủ - ngày mai intensive coding! 💪
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 9!
Forms là nền tảng của hầu hết web apps. Kiến thức hôm nay sẽ được dùng LIÊN TỤC trong các ngày sau. Practice nhiều để thành thạo! 🚀