📅 NGÀY 41: React Hook Form - Basics
🎯 Mục tiêu học tập (5 phút)
- [ ] Hiểu vấn đề của form trong React và tại sao cần library
- [ ] Nắm vững React Hook Form API cơ bản (useForm, register, handleSubmit)
- [ ] Biết cách validation với built-in rules
- [ ] Hiểu performance benefits của uncontrolled components
- [ ] So sánh được controlled vs uncontrolled forms
🤔 Kiểm tra đầu vào (5 phút)
- Controlled Components (Ngày 13): Form inputs với useState hoạt động như thế nào?
- Custom Hooks (Ngày 24): Làm sao extract logic vào custom hook?
- Performance (Ngày 32-34): Re-render xảy ra khi nào? useMemo/useCallback dùng để làm gì?
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế - Forms trong React
Tình huống: Bạn cần build form đăng ký với 10 fields
Approach 1: Manual với useState (Traditional) ❌
function RegistrationForm() {
// 😱 10 useState cho 10 fields!
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [address, setAddress] = useState('');
const [city, setCity] = useState('');
const [country, setCountry] = useState('');
const [zipCode, setZipCode] = useState('');
// 😱 10 error states!
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
// ... 8 more
// 😱 10 touched states!
const [emailTouched, setEmailTouched] = useState(false);
// ... 9 more
// 😱 10 onChange handlers!
const handleEmailChange = (e) => {
setEmail(e.target.value);
validateEmail(e.target.value);
};
// ... 9 more
// 😱 Performance nightmare: 30 re-renders per keystroke!
}Vấn đề:
- 🔥 Quá nhiều boilerplate code
- 🔥 Re-render mỗi lần gõ 1 ký tự (toàn bộ component)
- 🔥 Khó quản lý khi form lớn
- 🔥 Validation logic lộn xộn
Approach 2: React Hook Form ✅
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data); // All form values!
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email required' })} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password', { required: true, minLength: 6 })} />
{errors.password && <span>Password must be 6+ characters</span>}
{/* 8 more fields - same pattern! */}
<button>Submit</button>
</form>
);
}Lợi ích:
- ✅ Ít code hơn (90% less boilerplate)
- ✅ Better performance (ít re-renders)
- ✅ Built-in validation
- ✅ Easy to scale
1.2 Giải Pháp - React Hook Form
React Hook Form là library để quản lý forms với:
- Uncontrolled components approach (ref-based)
- Minimal re-renders (chỉ re-render khi cần)
- Built-in validation (không cần thư viện khác)
- TypeScript support
- Small bundle size (~8KB)
Core concepts:
useForm()- Hook chínhregister()- Đăng ký inputhandleSubmit()- Handle submitformState- Form state (errors, isDirty, isValid, etc.)
1.3 Mental Model
CONTROLLED vs UNCONTROLLED FORMS:
CONTROLLED (Traditional React):
┌─────────────────────────────────┐
│ Component State (useState) │
│ ┌─────────────────────────────┐ │
│ │ value = "abc" │ │
│ └─────────────────────────────┘ │
│ ↓↑ (sync) │
│ ┌─────────────────────────────┐ │
│ │ <input value={value} /> │ │
│ │ onChange={...} │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
Mỗi keystroke → setState → re-render
⚠️ Performance issue với large forms
UNCONTROLLED (React Hook Form):
┌─────────────────────────────────┐
│ DOM (Native Input) │
│ ┌─────────────────────────────┐ │
│ │ <input ref={register} /> │ │
│ │ value lives in DOM │ │
│ └─────────────────────────────┘ │
│ ↓ (on submit) │
│ ┌─────────────────────────────┐ │
│ │ React Hook Form │ │
│ │ reads value via ref │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
Typing → NO re-render
Submit → read all values at once
✅ Better performance
Analogy:
- Controlled = Micromanager (theo dõi mọi thứ)
- Uncontrolled = Trust & verify (để tự do, check khi cần)1.4 Hiểu Lầm Phổ Biến
❌ "React Hook Form chỉ cho uncontrolled components" → Sai! Có thể dùng với controlled components qua watch() và setValue().
❌ "Uncontrolled = không control được" → Sai! Vẫn control được, chỉ khác cách. Control qua ref thay vì state.
❌ "React Hook Form thay thế tất cả useState cho forms" → Không hẳn. Simple forms (1-2 fields) có thể vẫn dùng useState.
❌ "Không re-render = không thấy errors" → Sai! Errors vẫn hiển thị, RHF re-render khi errors thay đổi.
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Basic Form - So sánh Approaches ⭐
Login form: Manual vs React Hook Form
💡 Code Example
/**
* Login Form - Comparing Manual vs React Hook Form
*
* Demo shows:
* - Manual approach (useState)
* - React Hook Form approach
* - Performance difference
*/
import { useState } from 'react';
import { useForm } from 'react-hook-form';
// ❌ MANUAL APPROACH - Traditional way
function LoginFormManual() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [renderCount, setRenderCount] = useState(0);
// Track re-renders
useState(() => {
setRenderCount((prev) => prev + 1);
});
const validateEmail = (value) => {
if (!value) return 'Email is required';
if (!/\S+@\S+\.\S+/.test(value)) return 'Email is invalid';
return '';
};
const validatePassword = (value) => {
if (!value) return 'Password is required';
if (value.length < 6) return 'Password must be at least 6 characters';
return '';
};
const handleEmailChange = (e) => {
const value = e.target.value;
setEmail(value);
// Validate on change
const error = validateEmail(value);
setErrors((prev) => ({ ...prev, email: error }));
};
const handlePasswordChange = (e) => {
const value = e.target.value;
setPassword(value);
const error = validatePassword(value);
setErrors((prev) => ({ ...prev, password: error }));
};
const handleSubmit = (e) => {
e.preventDefault();
const emailError = validateEmail(email);
const passwordError = validatePassword(password);
if (emailError || passwordError) {
setErrors({ email: emailError, password: passwordError });
return;
}
console.log('Manual form submitted:', { email, password });
alert('Login successful!');
};
return (
<form
onSubmit={handleSubmit}
style={{ padding: '20px', border: '2px solid orange' }}
>
<h3>Manual Approach (useState)</h3>
<p style={{ color: 'orange' }}>Render count: {renderCount}</p>
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='manual-email'
style={{ display: 'block', marginBottom: '4px' }}
>
Email
</label>
<input
id='manual-email'
type='email'
value={email}
onChange={handleEmailChange}
style={{
width: '100%',
padding: '8px',
border: errors.email ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.email && (
<span style={{ color: 'red', fontSize: '14px' }}>{errors.email}</span>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='manual-password'
style={{ display: 'block', marginBottom: '4px' }}
>
Password
</label>
<input
id='manual-password'
type='password'
value={password}
onChange={handlePasswordChange}
style={{
width: '100%',
padding: '8px',
border: errors.password ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.password && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.password}
</span>
)}
</div>
<button
type='submit'
style={{
padding: '10px 20px',
backgroundColor: '#ff9800',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Login (Manual)
</button>
</form>
);
}
// ✅ REACT HOOK FORM APPROACH - Modern way
function LoginFormRHF() {
const [renderCount, setRenderCount] = useState(0);
// Track re-renders
useState(() => {
setRenderCount((prev) => prev + 1);
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
mode: 'onChange', // Validate on change
});
const onSubmit = (data) => {
console.log('RHF form submitted:', data);
alert('Login successful!');
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{ padding: '20px', border: '2px solid green' }}
>
<h3>React Hook Form</h3>
<p style={{ color: 'green' }}>Render count: {renderCount}</p>
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='rhf-email'
style={{ display: 'block', marginBottom: '4px' }}
>
Email
</label>
<input
id='rhf-email'
{...register('email', {
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Email is invalid',
},
})}
style={{
width: '100%',
padding: '8px',
border: errors.email ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.email && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='rhf-password'
style={{ display: 'block', marginBottom: '4px' }}
>
Password
</label>
<input
id='rhf-password'
type='password'
{...register('password', {
required: 'Password is required',
minLength: {
value: 6,
message: 'Password must be at least 6 characters',
},
})}
style={{
width: '100%',
padding: '8px',
border: errors.password ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.password && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.password.message}
</span>
)}
</div>
<button
type='submit'
style={{
padding: '10px 20px',
backgroundColor: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Login (RHF)
</button>
</form>
);
}
// Compare side by side
function App() {
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h1>Login Form Comparison</h1>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px',
marginTop: '20px',
}}
>
<LoginFormManual />
<LoginFormRHF />
</div>
<div
style={{
marginTop: '40px',
padding: '20px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
}}
>
<h3>📊 Observe the differences:</h3>
<ul>
<li>
<strong>Render Count:</strong>
<ul>
<li>Manual: Increases on EVERY keystroke</li>
<li>RHF: Only increases on validation errors (much less!)</li>
</ul>
</li>
<li>
<strong>Code Amount:</strong>
<ul>
<li>Manual: ~80 lines (lots of boilerplate)</li>
<li>RHF: ~50 lines (cleaner!)</li>
</ul>
</li>
<li>
<strong>Validation:</strong>
<ul>
<li>Manual: Write validation functions manually</li>
<li>RHF: Built-in validation rules</li>
</ul>
</li>
</ul>
</div>
</div>
);
}
/*
Key Takeaways:
Manual Approach:
❌많은 re-renders (performance issue)
❌ Lots of boilerplate
❌ Manual validation logic
❌ Hard to scale
React Hook Form:
✅ Minimal re-renders (better performance)
✅ Less code
✅ Built-in validation
✅ Easy to scale
When to use each:
- Manual: Very simple forms (1-2 fields)
- RHF: Medium to large forms (3+ fields)
*/Demo 2: Validation Rules ⭐⭐
All built-in validation options
💡 Code Example
/**
* React Hook Form - Validation Rules Demo
*
* Shows all built-in validation options:
* - required
* - minLength / maxLength
* - min / max (numbers)
* - pattern (regex)
* - validate (custom function)
*/
import { useForm } from 'react-hook-form';
function ValidationDemo() {
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm({
mode: 'onBlur', // Validate on blur
});
const password = watch('password'); // Watch password for confirm validation
const onSubmit = (data) => {
console.log('Form data:', data);
alert('Form submitted successfully!');
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{ maxWidth: '500px', padding: '20px' }}
>
<h2>Validation Rules Demo</h2>
{/* 1. REQUIRED */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}
>
1. Required Field
</label>
<input
{...register('username', {
required: 'Username is required',
})}
placeholder='Enter username'
style={{
width: '100%',
padding: '8px',
border: errors.username ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.username && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.username.message}
</span>
)}
</div>
{/* 2. MIN/MAX LENGTH */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}
>
2. Min/Max Length (6-20 chars)
</label>
<input
type='password'
{...register('password', {
required: 'Password is required',
minLength: {
value: 6,
message: 'Password must be at least 6 characters',
},
maxLength: {
value: 20,
message: 'Password must not exceed 20 characters',
},
})}
placeholder='Enter password'
style={{
width: '100%',
padding: '8px',
border: errors.password ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.password && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.password.message}
</span>
)}
</div>
{/* 3. PATTERN (Regex) */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}
>
3. Pattern - Email Format
</label>
<input
type='email'
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
placeholder='user@example.com'
style={{
width: '100%',
padding: '8px',
border: errors.email ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.email && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.email.message}
</span>
)}
</div>
{/* 4. MIN/MAX (Numbers) */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}
>
4. Min/Max Value - Age (18-100)
</label>
<input
type='number'
{...register('age', {
required: 'Age is required',
min: {
value: 18,
message: 'You must be at least 18 years old',
},
max: {
value: 100,
message: 'Age must not exceed 100',
},
})}
placeholder='Enter age'
style={{
width: '100%',
padding: '8px',
border: errors.age ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.age && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.age.message}
</span>
)}
</div>
{/* 5. VALIDATE (Custom function) */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}
>
5. Custom Validation - Confirm Password
</label>
<input
type='password'
{...register('confirmPassword', {
required: 'Please confirm password',
validate: (value) => value === password || 'Passwords do not match',
})}
placeholder='Confirm password'
style={{
width: '100%',
padding: '8px',
border: errors.confirmPassword ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.confirmPassword && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.confirmPassword.message}
</span>
)}
</div>
{/* 6. MULTIPLE VALIDATIONS */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}
>
6. Multiple Custom Validations - Username
</label>
<input
{...register('customUsername', {
required: 'Username is required',
validate: {
noSpaces: (value) =>
!/\s/.test(value) || 'Username cannot contain spaces',
noSpecialChars: (value) =>
/^[a-zA-Z0-9_]+$/.test(value) ||
'Only letters, numbers, and underscore allowed',
minLength: (value) =>
value.length >= 3 || 'Username must be at least 3 characters',
},
})}
placeholder='username_123'
style={{
width: '100%',
padding: '8px',
border: errors.customUsername ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.customUsername && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.customUsername.message}
</span>
)}
</div>
{/* 7. CONDITIONAL VALIDATION */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}
>
7. Conditional - Phone (required if age < 18)
</label>
<input
{...register('phone', {
validate: (value) => {
const age = watch('age');
if (age && age < 18 && !value) {
return 'Phone is required for users under 18';
}
return true;
},
})}
placeholder='Phone number'
style={{
width: '100%',
padding: '8px',
border: errors.phone ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.phone && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.phone.message}
</span>
)}
</div>
<button
type='submit'
style={{
width: '100%',
padding: '12px',
backgroundColor: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Submit
</button>
<div
style={{
marginTop: '20px',
padding: '16px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
}}
>
<h4>📋 Validation Rules Summary:</h4>
<ol style={{ margin: '8px 0', paddingLeft: '20px' }}>
<li>
<code>required</code> - Field cannot be empty
</li>
<li>
<code>minLength / maxLength</code> - String length limits
</li>
<li>
<code>pattern</code> - Regex validation
</li>
<li>
<code>min / max</code> - Number range
</li>
<li>
<code>validate</code> - Custom function (single)
</li>
<li>
<code>validate: {`{}`}</code> - Multiple custom functions
</li>
<li>
Conditional validation using <code>watch()</code>
</li>
</ol>
</div>
</form>
);
}
/*
Validation Rules Reference:
1. required: 'Message' | boolean
2. minLength: { value: number, message: string }
3. maxLength: { value: number, message: string }
4. min: { value: number, message: string }
5. max: { value: number, message: string }
6. pattern: { value: RegExp, message: string }
7. validate: (value) => boolean | string
8. validate: {
rule1: (value) => boolean | string,
rule2: (value) => boolean | string
}
Tips:
- Use mode: 'onBlur' for better UX (validate after blur)
- Use mode: 'onChange' for real-time validation
- Use mode: 'onSubmit' (default) for validation only on submit
- Custom validate can access other field values via watch()
*/Demo 3: Form State & Features ⭐⭐⭐
Using formState and other RHF features
💡 Code Example
/**
* React Hook Form - Form State & Features
*
* Demonstrates:
* - formState properties
* - reset()
* - watch()
* - setValue()
* - Disabled state
*/
import { useForm } from 'react-hook-form';
import { useState } from 'react';
function FormStateDemo() {
const {
register,
handleSubmit,
formState: {
errors,
isDirty,
isValid,
isSubmitting,
touchedFields,
dirtyFields,
},
watch,
reset,
setValue,
} = useForm({
mode: 'onChange', // Validate on change to see isValid
defaultValues: {
firstName: '',
lastName: '',
email: '',
subscribe: false,
},
});
const [submittedData, setSubmittedData] = useState(null);
// Watch specific field
const watchFirstName = watch('firstName');
// Watch all fields
const watchAllFields = watch();
const onSubmit = async (data) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('Submitted:', data);
setSubmittedData(data);
};
return (
<div style={{ maxWidth: '600px', padding: '20px' }}>
<h2>Form State & Features Demo</h2>
{/* Form State Display */}
<div
style={{
marginBottom: '20px',
padding: '16px',
backgroundColor: '#e3f2fd',
borderRadius: '8px',
}}
>
<h3>📊 Form State:</h3>
<ul style={{ margin: 0, paddingLeft: '20px' }}>
<li>
<strong>isDirty:</strong> {isDirty ? '✅ Yes' : '❌ No'}
<small> (any field changed?)</small>
</li>
<li>
<strong>isValid:</strong> {isValid ? '✅ Yes' : '❌ No'}
<small> (all validations pass?)</small>
</li>
<li>
<strong>isSubmitting:</strong> {isSubmitting ? '⏳ Yes' : '❌ No'}
<small> (form being submitted?)</small>
</li>
<li>
<strong>touchedFields:</strong>{' '}
{Object.keys(touchedFields).join(', ') || 'none'}
</li>
<li>
<strong>dirtyFields:</strong>{' '}
{Object.keys(dirtyFields).join(', ') || 'none'}
</li>
</ul>
</div>
{/* Live Watch */}
<div
style={{
marginBottom: '20px',
padding: '16px',
backgroundColor: '#fff3e0',
borderRadius: '8px',
}}
>
<h3>👁️ Watch Values:</h3>
<p>
<strong>First Name:</strong> {watchFirstName || '(empty)'}
</p>
<pre
style={{
backgroundColor: '#f5f5f5',
padding: '8px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '12px',
}}
>
{JSON.stringify(watchAllFields, null, 2)}
</pre>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
{/* First Name */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
marginBottom: '4px',
fontWeight: 'bold',
}}
>
First Name *
</label>
<input
{...register('firstName', {
required: 'First name is required',
minLength: {
value: 2,
message: 'Must be at least 2 characters',
},
})}
style={{
width: '100%',
padding: '8px',
border: errors.firstName ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.firstName && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.firstName.message}
</span>
)}
</div>
{/* Last Name */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
marginBottom: '4px',
fontWeight: 'bold',
}}
>
Last Name *
</label>
<input
{...register('lastName', {
required: 'Last name is required',
})}
style={{
width: '100%',
padding: '8px',
border: errors.lastName ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.lastName && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.lastName.message}
</span>
)}
</div>
{/* Email */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
marginBottom: '4px',
fontWeight: 'bold',
}}
>
Email *
</label>
<input
type='email'
{...register('email', {
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Invalid email',
},
})}
style={{
width: '100%',
padding: '8px',
border: errors.email ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.email && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.email.message}
</span>
)}
</div>
{/* Checkbox */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type='checkbox'
{...register('subscribe')}
/>
<span>Subscribe to newsletter</span>
</label>
</div>
{/* Buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<button
type='submit'
disabled={isSubmitting || !isValid}
style={{
flex: 1,
padding: '12px',
backgroundColor: isSubmitting || !isValid ? '#ccc' : '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isSubmitting || !isValid ? 'not-allowed' : 'pointer',
}}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
<button
type='button'
onClick={() => reset()}
disabled={!isDirty}
style={{
flex: 1,
padding: '12px',
backgroundColor: !isDirty ? '#ccc' : '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: !isDirty ? 'not-allowed' : 'pointer',
}}
>
Reset
</button>
<button
type='button'
onClick={() => {
setValue('firstName', 'John', {
shouldValidate: true,
shouldDirty: true,
});
setValue('lastName', 'Doe', {
shouldValidate: true,
shouldDirty: true,
});
setValue('email', 'john@example.com', {
shouldValidate: true,
shouldDirty: true,
});
}}
style={{
flex: 1,
padding: '12px',
backgroundColor: '#2196f3',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Fill Demo Data
</button>
</div>
</form>
{/* Submitted Data Display */}
{submittedData && (
<div
style={{
marginTop: '20px',
padding: '16px',
backgroundColor: '#e8f5e9',
borderRadius: '8px',
}}
>
<h3>✅ Form Submitted Successfully!</h3>
<pre
style={{
backgroundColor: '#f5f5f5',
padding: '12px',
borderRadius: '4px',
overflow: 'auto',
}}
>
{JSON.stringify(submittedData, null, 2)}
</pre>
</div>
)}
{/* Documentation */}
<div
style={{
marginTop: '20px',
padding: '16px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
}}
>
<h4>📚 Features Used:</h4>
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
<li>
<code>formState.isDirty</code> - Any field changed?
</li>
<li>
<code>formState.isValid</code> - All validations pass?
</li>
<li>
<code>formState.isSubmitting</code> - Currently submitting?
</li>
<li>
<code>watch()</code> - Get current field values
</li>
<li>
<code>reset()</code> - Reset form to default
</li>
<li>
<code>setValue()</code> - Programmatically set value
</li>
</ul>
</div>
</div>
);
}
/*
formState Properties:
- isDirty: boolean - Any field modified?
- isValid: boolean - All validations pass?
- isSubmitting: boolean - Form being submitted?
- isSubmitted: boolean - Form was submitted?
- touchedFields: object - Fields that were focused
- dirtyFields: object - Fields that were changed
- errors: object - Validation errors
Methods:
- watch(name?) - Watch field values
- reset(values?) - Reset form
- setValue(name, value, options?) - Set value programmatically
- getValues() - Get all form values
- trigger(name?) - Trigger validation manually
Options for setValue:
- shouldValidate: boolean - Trigger validation?
- shouldDirty: boolean - Mark as dirty?
- shouldTouch: boolean - Mark as touched?
*/🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Bài 1: Simple Contact Form (15 phút)
🎯 Mục tiêu: Làm quen với RHF basic API
/**
* 🎯 Mục tiêu: Tạo contact form với React Hook Form
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: Zod, Yup (chưa học)
*
* Requirements:
* 1. Fields: name, email, message
* 2. Validation:
* - All fields required
* - Email format validation
* - Message min length: 10 chars
* 3. Show errors on blur
* 4. Disable submit nếu có errors
* 5. Reset form sau khi submit thành công
*
* 💡 Gợi ý:
* - Dùng mode: 'onBlur'
* - Dùng formState.isValid
* - Dùng reset() sau submit
*/
// 🎯 NHIỆM VỤ CỦA BẠN:
// TODO: Implement ContactForm với React Hook Form💡 Solution
/**
* Contact Form - React Hook Form Solution
*/
import { useForm } from 'react-hook-form';
import { useState } from 'react';
function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isValid },
reset,
} = useForm({
mode: 'onBlur', // Validate on blur
});
const [submitStatus, setSubmitStatus] = useState(null);
const onSubmit = (data) => {
console.log('Contact form submitted:', data);
// Simulate API call
setTimeout(() => {
setSubmitStatus('success');
reset(); // Reset form after successful submit
// Clear success message after 3s
setTimeout(() => setSubmitStatus(null), 3000);
}, 500);
};
return (
<div style={{ maxWidth: '500px', padding: '20px' }}>
<h2>Contact Us</h2>
{submitStatus === 'success' && (
<div
style={{
padding: '12px',
backgroundColor: '#d4edda',
border: '1px solid #c3e6cb',
borderRadius: '4px',
marginBottom: '16px',
color: '#155724',
}}
>
✅ Message sent successfully!
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{/* Name */}
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='name'
style={{ display: 'block', marginBottom: '4px' }}
>
Name *
</label>
<input
id='name'
{...register('name', {
required: 'Name is required',
})}
placeholder='Your name'
style={{
width: '100%',
padding: '8px',
border: errors.name ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.name && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.name.message}
</span>
)}
</div>
{/* Email */}
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='email'
style={{ display: 'block', marginBottom: '4px' }}
>
Email *
</label>
<input
id='email'
type='email'
{...register('email', {
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Please enter a valid email address',
},
})}
placeholder='your@email.com'
style={{
width: '100%',
padding: '8px',
border: errors.email ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.email && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.email.message}
</span>
)}
</div>
{/* Message */}
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='message'
style={{ display: 'block', marginBottom: '4px' }}
>
Message *
</label>
<textarea
id='message'
{...register('message', {
required: 'Message is required',
minLength: {
value: 10,
message: 'Message must be at least 10 characters',
},
})}
placeholder='Your message...'
rows={4}
style={{
width: '100%',
padding: '8px',
border: errors.message ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
fontFamily: 'inherit',
resize: 'vertical',
}}
/>
{errors.message && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.message.message}
</span>
)}
</div>
{/* Submit */}
<button
type='submit'
disabled={!isValid}
style={{
width: '100%',
padding: '12px',
backgroundColor: !isValid ? '#ccc' : '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: !isValid ? 'not-allowed' : 'pointer',
}}
>
Send Message
</button>
</form>
</div>
);
}
/*
Kết quả:
✅ Basic RHF setup
✅ Validation on blur
✅ Error messages
✅ Disabled state
✅ Form reset after submit
*/⭐⭐ Bài 2: Registration Form với Password Strength (25 phút)
🎯 Mục tiêu: Advanced validation patterns
/**
* 🎯 Mục tiêu: Registration form với custom validation
* ⏱️ Thời gian: 25 phút
*
* Scenario: User registration với password strength indicator
*
* Requirements:
* 1. Fields: username, email, password, confirmPassword
* 2. Username validation:
* - Required
* - Min 3 chars
* - No spaces
* - Alphanumeric + underscore only
* 3. Password validation:
* - Required
* - Min 8 chars
* - Must contain: uppercase, lowercase, number
* 4. Password strength indicator:
* - Weak (< 8 chars)
* - Medium (8+ chars, 2/3 requirements)
* - Strong (8+ chars, all requirements)
* 5. Confirm password must match
* 6. Show live validation feedback
*
* 💡 Gợi ý:
* - Use validate object for multiple rules
* - Use watch() for password strength
* - Use custom validation function
*/
// 🎯 NHIỆM VỤ CỦA BẠN:
// TODO: Implement RegistrationForm💡 Solution
/**
* Registration Form with Password Strength
*/
import { useForm } from 'react-hook-form';
import { useState, useMemo } from 'react';
// Password strength calculator
function calculatePasswordStrength(password) {
if (!password) return { strength: 'none', score: 0 };
let score = 0;
const checks = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[!@#$%^&*]/.test(password),
};
if (checks.length) score++;
if (checks.uppercase) score++;
if (checks.lowercase) score++;
if (checks.number) score++;
if (checks.special) score++;
let strength = 'weak';
if (score >= 4) strength = 'strong';
else if (score >= 2) strength = 'medium';
return { strength, score, checks };
}
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
reset,
} = useForm({
mode: 'onChange', // Real-time validation
});
const [submitStatus, setSubmitStatus] = useState(null);
const password = watch('password');
const passwordStrength = useMemo(
() => calculatePasswordStrength(password),
[password],
);
const onSubmit = async (data) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log('Registration data:', data);
setSubmitStatus('success');
reset();
setTimeout(() => setSubmitStatus(null), 3000);
};
// Strength indicator colors
const strengthColors = {
none: '#ccc',
weak: '#f44336',
medium: '#ff9800',
strong: '#4caf50',
};
return (
<div style={{ maxWidth: '500px', padding: '20px' }}>
<h2>Create Account</h2>
{submitStatus === 'success' && (
<div
style={{
padding: '12px',
backgroundColor: '#d4edda',
border: '1px solid #c3e6cb',
borderRadius: '4px',
marginBottom: '16px',
color: '#155724',
}}
>
✅ Account created successfully!
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
{/* Username */}
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='username'
style={{ display: 'block', marginBottom: '4px' }}
>
Username *
</label>
<input
id='username'
{...register('username', {
required: 'Username is required',
minLength: {
value: 3,
message: 'Username must be at least 3 characters',
},
validate: {
noSpaces: (value) =>
!/\s/.test(value) || 'Username cannot contain spaces',
alphanumeric: (value) =>
/^[a-zA-Z0-9_]+$/.test(value) ||
'Only letters, numbers, and underscore allowed',
},
})}
placeholder='username_123'
style={{
width: '100%',
padding: '8px',
border: errors.username ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.username && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.username.message}
</span>
)}
</div>
{/* Email */}
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='email'
style={{ display: 'block', marginBottom: '4px' }}
>
Email *
</label>
<input
id='email'
type='email'
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
placeholder='your@email.com'
style={{
width: '100%',
padding: '8px',
border: errors.email ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.email && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.email.message}
</span>
)}
</div>
{/* Password */}
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='password'
style={{ display: 'block', marginBottom: '4px' }}
>
Password *
</label>
<input
id='password'
type='password'
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
validate: {
hasUppercase: (value) =>
/[A-Z]/.test(value) || 'Must contain uppercase letter',
hasLowercase: (value) =>
/[a-z]/.test(value) || 'Must contain lowercase letter',
hasNumber: (value) =>
/[0-9]/.test(value) || 'Must contain number',
},
})}
placeholder='********'
style={{
width: '100%',
padding: '8px',
border: errors.password ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.password && (
<span style={{ color: 'red', fontSize: '14px', display: 'block' }}>
{errors.password.message}
</span>
)}
{/* Password Strength Indicator */}
{password && (
<div style={{ marginTop: '8px' }}>
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<div
style={{
flex: 1,
height: '4px',
backgroundColor:
passwordStrength.score >= 1
? strengthColors[passwordStrength.strength]
: '#e0e0e0',
borderRadius: '2px',
}}
/>
<div
style={{
flex: 1,
height: '4px',
backgroundColor:
passwordStrength.score >= 2
? strengthColors[passwordStrength.strength]
: '#e0e0e0',
borderRadius: '2px',
}}
/>
<div
style={{
flex: 1,
height: '4px',
backgroundColor:
passwordStrength.score >= 3
? strengthColors[passwordStrength.strength]
: '#e0e0e0',
borderRadius: '2px',
}}
/>
<div
style={{
flex: 1,
height: '4px',
backgroundColor:
passwordStrength.score >= 4
? strengthColors[passwordStrength.strength]
: '#e0e0e0',
borderRadius: '2px',
}}
/>
</div>
<span
style={{
fontSize: '12px',
color: strengthColors[passwordStrength.strength],
textTransform: 'capitalize',
}}
>
{passwordStrength.strength} password
</span>
<div
style={{ fontSize: '12px', marginTop: '4px', color: '#666' }}
>
✓ Requirements:
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
<li
style={{
color: passwordStrength.checks.length
? 'green'
: 'inherit',
}}
>
{passwordStrength.checks.length ? '✓' : '○'} 8+ characters
</li>
<li
style={{
color: passwordStrength.checks.uppercase
? 'green'
: 'inherit',
}}
>
{passwordStrength.checks.uppercase ? '✓' : '○'} Uppercase
letter
</li>
<li
style={{
color: passwordStrength.checks.lowercase
? 'green'
: 'inherit',
}}
>
{passwordStrength.checks.lowercase ? '✓' : '○'} Lowercase
letter
</li>
<li
style={{
color: passwordStrength.checks.number
? 'green'
: 'inherit',
}}
>
{passwordStrength.checks.number ? '✓' : '○'} Number
</li>
</ul>
</div>
</div>
)}
</div>
{/* Confirm Password */}
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='confirmPassword'
style={{ display: 'block', marginBottom: '4px' }}
>
Confirm Password *
</label>
<input
id='confirmPassword'
type='password'
{...register('confirmPassword', {
required: 'Please confirm your password',
validate: (value) =>
value === password || 'Passwords do not match',
})}
placeholder='********'
style={{
width: '100%',
padding: '8px',
border: errors.confirmPassword
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.confirmPassword && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.confirmPassword.message}
</span>
)}
</div>
{/* Submit */}
<button
type='submit'
disabled={isSubmitting}
style={{
width: '100%',
padding: '12px',
backgroundColor: isSubmitting ? '#ccc' : '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
}}
>
{isSubmitting ? 'Creating Account...' : 'Create Account'}
</button>
</form>
</div>
);
}
/*
Kết quả:
✅ Multiple validation rules per field
✅ Live password strength indicator
✅ Password match validation
✅ Custom validation functions
✅ Real-time feedback
✅ Good UX with visual indicators
*/⭐⭐⭐ Bài 3: Dynamic Form Fields (40 phút)
🎯 Mục tiêu: Array fields management
/**
* 🎯 Mục tiêu: Form với dynamic array fields
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là HR, tôi muốn thêm/xóa nhiều work experiences
* để có thể track hết quá trình làm việc của candidate"
*
* ✅ Acceptance Criteria:
* - [ ] Add new work experience entry
* - [ ] Remove work experience entry
* - [ ] Each entry có: company, position, startDate, endDate
* - [ ] Validation: endDate > startDate
* - [ ] At least 1 work experience required
* - [ ] Max 5 work experiences
*
* 🎨 Technical Constraints:
* - Use useFieldArray từ React Hook Form
* - Proper validation for each field
* - Clear UX for add/remove
*
* 🚨 Edge Cases:
* - Cannot remove last item
* - Cannot add more than 5 items
* - Date validation
*/
// 🎯 NHIỆM VỤ CỦA BẠN:
// TODO: Implement WorkExperienceForm với useFieldArray💡 Solution
/**
* Work Experience Form - Dynamic Fields với useFieldArray
*/
import { useForm, useFieldArray } from 'react-hook-form';
function WorkExperienceForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
watch,
} = useForm({
defaultValues: {
experiences: [
{
company: '',
position: '',
startDate: '',
endDate: '',
},
],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'experiences',
});
const onSubmit = (data) => {
console.log('Work experiences:', data);
alert('Form submitted! Check console.');
};
// Validate end date > start date
const validateEndDate = (endDate, index) => {
const startDate = watch(`experiences.${index}.startDate`);
if (!startDate || !endDate) return true;
return (
new Date(endDate) > new Date(startDate) ||
'End date must be after start date'
);
};
return (
<div style={{ maxWidth: '700px', padding: '20px' }}>
<h2>Work Experience</h2>
<p style={{ color: '#666' }}>
Add your work experiences (minimum 1, maximum 5)
</p>
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div
key={field.id}
style={{
marginBottom: '24px',
padding: '16px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
backgroundColor: '#f9f9f9',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
}}
>
<h3 style={{ margin: 0 }}>Experience #{index + 1}</h3>
{fields.length > 1 && (
<button
type='button'
onClick={() => remove(index)}
style={{
padding: '6px 12px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Remove
</button>
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
}}
>
{/* Company */}
<div>
<label
style={{
display: 'block',
marginBottom: '4px',
fontSize: '14px',
}}
>
Company *
</label>
<input
{...register(`experiences.${index}.company`, {
required: 'Company name is required',
})}
placeholder='Company name'
style={{
width: '100%',
padding: '8px',
border: errors.experiences?.[index]?.company
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.experiences?.[index]?.company && (
<span style={{ color: 'red', fontSize: '12px' }}>
{errors.experiences[index].company.message}
</span>
)}
</div>
{/* Position */}
<div>
<label
style={{
display: 'block',
marginBottom: '4px',
fontSize: '14px',
}}
>
Position *
</label>
<input
{...register(`experiences.${index}.position`, {
required: 'Position is required',
})}
placeholder='Job title'
style={{
width: '100%',
padding: '8px',
border: errors.experiences?.[index]?.position
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.experiences?.[index]?.position && (
<span style={{ color: 'red', fontSize: '12px' }}>
{errors.experiences[index].position.message}
</span>
)}
</div>
{/* Start Date */}
<div>
<label
style={{
display: 'block',
marginBottom: '4px',
fontSize: '14px',
}}
>
Start Date *
</label>
<input
type='date'
{...register(`experiences.${index}.startDate`, {
required: 'Start date is required',
})}
style={{
width: '100%',
padding: '8px',
border: errors.experiences?.[index]?.startDate
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.experiences?.[index]?.startDate && (
<span style={{ color: 'red', fontSize: '12px' }}>
{errors.experiences[index].startDate.message}
</span>
)}
</div>
{/* End Date */}
<div>
<label
style={{
display: 'block',
marginBottom: '4px',
fontSize: '14px',
}}
>
End Date *
</label>
<input
type='date'
{...register(`experiences.${index}.endDate`, {
required: 'End date is required',
validate: (value) => validateEndDate(value, index),
})}
style={{
width: '100%',
padding: '8px',
border: errors.experiences?.[index]?.endDate
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.experiences?.[index]?.endDate && (
<span style={{ color: 'red', fontSize: '12px' }}>
{errors.experiences[index].endDate.message}
</span>
)}
</div>
</div>
</div>
))}
{/* Add Button */}
<button
type='button'
onClick={() =>
append({
company: '',
position: '',
startDate: '',
endDate: '',
})
}
disabled={fields.length >= 5}
style={{
width: '100%',
padding: '12px',
backgroundColor: fields.length >= 5 ? '#ccc' : '#2196f3',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: fields.length >= 5 ? 'not-allowed' : 'pointer',
marginBottom: '16px',
}}
>
➕ Add Work Experience {fields.length >= 5 && '(Max 5 reached)'}
</button>
{/* Submit */}
<button
type='submit'
style={{
width: '100%',
padding: '12px',
backgroundColor: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Submit
</button>
</form>
<div
style={{
marginTop: '20px',
padding: '16px',
backgroundColor: '#e3f2fd',
borderRadius: '8px',
}}
>
<h4>💡 Features Demonstrated:</h4>
<ul style={{ margin: '8px 0', paddingLeft: '20px', fontSize: '14px' }}>
<li>
Dynamic array fields với <code>useFieldArray</code>
</li>
<li>Add/Remove entries</li>
<li>Individual field validation</li>
<li>Cross-field validation (end date vs start date)</li>
<li>Min/Max constraints (1-5 entries)</li>
<li>
Unique IDs với <code>field.id</code>
</li>
</ul>
</div>
</div>
);
}
/*
useFieldArray API:
const { fields, append, remove, insert, update } = useFieldArray({
control,
name: 'arrayFieldName'
});
Methods:
- append(value) - Add to end
- prepend(value) - Add to start
- insert(index, value) - Insert at index
- remove(index) - Remove at index
- update(index, value) - Update at index
- replace(values) - Replace entire array
Important:
- Always use field.id as key (NOT index!)
- Default values needed in useForm()
- Proper nested error access: errors.array?.[index]?.field
*/⭐⭐⭐⭐ Bài 4: Form Architecture Decision (60 phút)
🎯 Mục tiêu: Đưa ra architectural choices có lý do
/**
* 🎯 Mục tiêu: Design form architecture cho Job Application
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Scenario: Job Application form với 3 sections:
* - Personal Info (6 fields)
* - Work Experience (dynamic array, max 5)
* - Skills & Preferences (multiple checkboxes, text areas)
*
* Questions to answer:
* 1. Single form vs Multi-step wizard?
* 2. Validation timing: onChange, onBlur, or onSubmit?
* 3. Error display strategy?
* 4. Save draft strategy (no backend yet)?
*
* Nhiệm vụ:
* 1. So sánh ít nhất 3 approaches
* 2. Document pros/cons mỗi approach
* 3. Chọn approach phù hợp nhất
* 4. Viết ADR (Architecture Decision Record)
*
* ADR Template:
* - Context: Vấn đề cần giải quyết
* - 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)
* Implement solution theo design đã chọn
*
* 🧪 PHASE 3: Testing (10 phút)
* - Manual testing checklist
* - Edge cases verification
*/💡 Solution
/**
* Job Application Form - Architecture Decision
*
* ADR (Architecture Decision Record)
* ================================
*
* Context:
* Need to build job application form with:
* - 15+ total fields across 3 sections
* - Dynamic work experience entries
* - Complex validation rules
* - Good UX for long forms
* - No backend yet (save draft to localStorage)
*
* Decision: Single-page form with sections + Smart validation
*
* Rationale:
* 1. Single page better than multi-step because:
* - Users can see all requirements upfront
* - Easier to review before submit
* - Less state management complexity
* - No router needed (not learned yet)
*
* 2. Validation strategy: onBlur + onChange for errors
* - onBlur: First validation (less annoying)
* - onChange: Show errors after first blur (real-time feedback)
* - Better UX than onChange only (too aggressive)
*
* 3. Error display: Inline + Summary at top
* - Inline: Immediate feedback
* - Summary: Overview of all errors on submit
*
* 4. Save draft: localStorage on field blur
* - Auto-save every field change (onBlur)
* - Restore on mount
* - Simple, no backend needed
*
* Consequences (Trade-offs):
* ✅ Pros:
* - Simple architecture
* - Good UX (see all fields)
* - Auto-save prevents data loss
* - Easy to implement
*
* ❌ Cons:
* - Long form might intimidate users
* - No progress indicator (vs multi-step)
* - localStorage limited to ~5MB
*
* Alternatives Considered:
* 1. Multi-step wizard
* - Rejected: Need router (not learned), more complex state
* 2. onChange validation only
* - Rejected: Too aggressive, bad UX
* 3. onSubmit validation only
* - Rejected: Late feedback, users frustrated
*/
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'job_application_draft';
function JobApplicationForm() {
const [errorSummary, setErrorSummary] = useState([]);
// Load draft from localStorage
const loadDraft = () => {
try {
const draft = localStorage.getItem(STORAGE_KEY);
return draft ? JSON.parse(draft) : getDefaultValues();
} catch {
return getDefaultValues();
}
};
const {
register,
handleSubmit,
formState: { errors, touchedFields },
watch,
reset,
} = useForm({
mode: 'onTouched', // Validate after first blur
defaultValues: loadDraft(),
});
// Auto-save to localStorage
const formValues = watch();
useEffect(() => {
const timeoutId = setTimeout(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formValues));
}, 500); // Debounce 500ms
return () => clearTimeout(timeoutId);
}, [formValues]);
const onSubmit = (data) => {
console.log('Application submitted:', data);
// Clear draft
localStorage.removeItem(STORAGE_KEY);
alert('Application submitted successfully!');
reset(getDefaultValues());
setErrorSummary([]);
};
const onError = (errors) => {
// Collect all error messages for summary
const messages = [];
Object.entries(errors).forEach(([field, error]) => {
if (error.message) {
messages.push({ field, message: error.message });
}
});
setErrorSummary(messages);
// Scroll to top to show summary
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const clearDraft = () => {
if (window.confirm('Clear saved draft?')) {
localStorage.removeItem(STORAGE_KEY);
reset(getDefaultValues());
}
};
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<h1>Job Application Form</h1>
{/* Error Summary */}
{errorSummary.length > 0 && (
<div
style={{
padding: '16px',
backgroundColor: '#ffebee',
border: '1px solid #f44336',
borderRadius: '8px',
marginBottom: '24px',
}}
>
<h3 style={{ margin: '0 0 8px 0', color: '#c62828' }}>
⚠️ Please fix {errorSummary.length} error(s):
</h3>
<ul style={{ margin: 0, paddingLeft: '20px' }}>
{errorSummary.map((err, i) => (
<li
key={i}
style={{ color: '#c62828' }}
>
<strong>{err.field}:</strong> {err.message}
</li>
))}
</ul>
</div>
)}
{/* Draft indicator */}
<div
style={{
padding: '12px',
backgroundColor: '#e3f2fd',
borderRadius: '4px',
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>💾 Draft auto-saved to your browser</span>
<button
type='button'
onClick={clearDraft}
style={{
padding: '6px 12px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Clear Draft
</button>
</div>
<form onSubmit={handleSubmit(onSubmit, onError)}>
{/* SECTION 1: Personal Information */}
<section
style={{
marginBottom: '32px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
}}
>
<h2>Personal Information</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
}}
>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
First Name *
</label>
<input
{...register('firstName', {
required: 'First name is required',
minLength: { value: 2, message: 'Min 2 characters' },
})}
style={{
width: '100%',
padding: '8px',
border: errors.firstName ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.firstName && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.firstName.message}
</span>
)}
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
Last Name *
</label>
<input
{...register('lastName', {
required: 'Last name is required',
})}
style={{
width: '100%',
padding: '8px',
border: errors.lastName ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.lastName && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.lastName.message}
</span>
)}
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
Email *
</label>
<input
type='email'
{...register('email', {
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Invalid email format',
},
})}
style={{
width: '100%',
padding: '8px',
border: errors.email ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.email && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.email.message}
</span>
)}
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
Phone *
</label>
<input
{...register('phone', {
required: 'Phone is required',
pattern: {
value: /^[0-9]{10,}$/,
message: 'Min 10 digits',
},
})}
placeholder='0123456789'
style={{
width: '100%',
padding: '8px',
border: errors.phone ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.phone && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.phone.message}
</span>
)}
</div>
</div>
<div style={{ marginTop: '16px' }}>
<label style={{ display: 'block', marginBottom: '4px' }}>
LinkedIn Profile URL
</label>
<input
{...register('linkedin', {
pattern: {
value: /^https:\/\/(www\.)?linkedin\.com\/.+/,
message: 'Must be valid LinkedIn URL',
},
})}
placeholder='https://linkedin.com/in/yourname'
style={{
width: '100%',
padding: '8px',
border: errors.linkedin ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.linkedin && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.linkedin.message}
</span>
)}
</div>
</section>
{/* SECTION 2: Work Experience */}
<section
style={{
marginBottom: '32px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
}}
>
<h2>Current/Latest Position</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
}}
>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
Company *
</label>
<input
{...register('currentCompany', {
required: 'Company is required',
})}
style={{
width: '100%',
padding: '8px',
border: errors.currentCompany
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.currentCompany && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.currentCompany.message}
</span>
)}
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
Position *
</label>
<input
{...register('currentPosition', {
required: 'Position is required',
})}
style={{
width: '100%',
padding: '8px',
border: errors.currentPosition
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.currentPosition && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.currentPosition.message}
</span>
)}
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
Years of Experience *
</label>
<input
type='number'
{...register('yearsOfExperience', {
required: 'Required',
min: { value: 0, message: 'Min 0' },
max: { value: 50, message: 'Max 50' },
})}
style={{
width: '100%',
padding: '8px',
border: errors.yearsOfExperience
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.yearsOfExperience && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.yearsOfExperience.message}
</span>
)}
</div>
</div>
</section>
{/* SECTION 3: Skills & Preferences */}
<section
style={{
marginBottom: '32px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
}}
>
<h2>Skills & Preferences</h2>
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
marginBottom: '8px',
fontWeight: 'bold',
}}
>
Technical Skills * (Select at least 1)
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
}}
>
{['React', 'Vue', 'Angular', 'Node.js', 'Python', 'Java'].map(
(skill) => (
<label
key={skill}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<input
type='checkbox'
value={skill}
{...register('skills', {
validate: (value) =>
(value && value.length > 0) ||
'Select at least 1 skill',
})}
/>
<span>{skill}</span>
</label>
),
)}
</div>
{errors.skills && (
<span
style={{
color: 'red',
fontSize: '14px',
display: 'block',
marginTop: '4px',
}}
>
{errors.skills.message}
</span>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '4px' }}>
Why do you want to join? * (Min 50 characters)
</label>
<textarea
{...register('motivation', {
required: 'This field is required',
minLength: {
value: 50,
message: 'Please write at least 50 characters',
},
})}
rows={4}
style={{
width: '100%',
padding: '8px',
border: errors.motivation ? '2px solid red' : '1px solid #ccc',
borderRadius: '4px',
fontFamily: 'inherit',
resize: 'vertical',
}}
/>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '4px',
}}
>
{errors.motivation && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.motivation.message}
</span>
)}
<span style={{ fontSize: '14px', color: '#666' }}>
{watch('motivation')?.length || 0} / 50 characters
</span>
</div>
</div>
<div>
<label style={{ display: 'block', marginBottom: '4px' }}>
Expected Salary (USD/year) *
</label>
<input
type='number'
{...register('expectedSalary', {
required: 'Expected salary is required',
min: { value: 1000, message: 'Min $1,000' },
max: { value: 500000, message: 'Max $500,000' },
})}
placeholder='50000'
style={{
width: '100%',
padding: '8px',
border: errors.expectedSalary
? '2px solid red'
: '1px solid #ccc',
borderRadius: '4px',
}}
/>
{errors.expectedSalary && (
<span style={{ color: 'red', fontSize: '14px' }}>
{errors.expectedSalary.message}
</span>
)}
</div>
</section>
{/* Submit Button */}
<button
type='submit'
style={{
width: '100%',
padding: '16px',
backgroundColor: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '18px',
fontWeight: 'bold',
cursor: 'pointer',
}}
>
Submit Application
</button>
</form>
{/* Testing Checklist */}
<div
style={{
marginTop: '40px',
padding: '20px',
backgroundColor: '#f5f5f5',
borderRadius: '8px',
}}
>
<h3>🧪 Manual Testing Checklist:</h3>
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
<li>✓ Submit empty form → See all errors</li>
<li>✓ Fill invalid email → See error on blur</li>
<li>✓ Fill valid data → Errors disappear</li>
<li>✓ Refresh page → Data persists (localStorage)</li>
<li>✓ Submit valid form → Success + clear draft</li>
<li>✓ Clear draft button → Form resets</li>
<li>✓ Type in text area → Character counter updates</li>
<li>✓ Select no skills → Error on submit</li>
</ul>
</div>
</div>
);
}
function getDefaultValues() {
return {
firstName: '',
lastName: '',
email: '',
phone: '',
linkedin: '',
currentCompany: '',
currentPosition: '',
yearsOfExperience: '',
skills: [],
motivation: '',
expectedSalary: '',
};
}
/*
Architecture Decisions Summary:
1. ✅ Single-page form
- Better overview
- Easier review
- No router complexity
2. ✅ onTouched validation mode
- Validate after first blur
- Show errors in real-time after
- Good UX balance
3. ✅ Error summary at top
- Overview of all errors
- Scroll to top on submit error
- Clear visibility
4. ✅ Auto-save to localStorage
- Debounced (500ms)
- Prevent data loss
- Simple implementation
Trade-offs accepted:
- Long form (but organized in sections)
- No progress bar (but clear sections)
- localStorage limit (acceptable for form data)
*/⭐⭐⭐⭐⭐ Bài 5: Production-Ready Survey Form (90 phút)
🎯 Mục tiêu: Code sẵn sàng ship to production
/**
* 🎯 Mục tiêu: Production-ready Survey Form
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
* Customer Satisfaction Survey với:
* - Multiple question types (text, radio, checkbox, rating, textarea)
* - Conditional questions (show based on previous answers)
* - Progress tracking
* - Save & continue later
* - Export results
*
* 🏗️ Technical Design Doc:
* 1. Component Architecture:
* - SurveyForm (container)
* - QuestionRenderer (dynamic)
* - ProgressBar
* - ResultsSummary
*
* 2. State Management Strategy:
* - React Hook Form for form state
* - localStorage for persistence
* - Context for survey config (if needed)
*
* 3. Validation Strategy:
* - Required fields
* - Conditional validation
* - Min/max constraints
*
* 4. Performance Considerations:
* - Memoize expensive components
* - Debounce auto-save
* - Optimize re-renders
*
* 5. Error Handling Strategy:
* - Validation errors
* - localStorage errors
* - Edge cases
*
* ✅ Production Checklist:
* - [ ] All fields validated properly
* - [ ] Error states handled
* - [ ] Loading states (if async)
* - [ ] Empty states
* - [ ] A11y: keyboard navigation
* - [ ] A11y: ARIA labels
* - [ ] A11y: focus management
* - [ ] Mobile responsive (basic)
* - [ ] Auto-save works
* - [ ] Export functionality
* - [ ] Clear/reset functionality
* - [ ] Progress indicator
* - [ ] Conditional logic works
*/💡 Solution
/**
* Production-Ready Customer Satisfaction Survey
*
* Features:
* - Multiple question types
* - Conditional questions
* - Progress tracking
* - Auto-save & restore
* - Export results
* - Full accessibility
* - Error handling
*/
import { useForm } from 'react-hook-form';
import { useState, useEffect, useMemo, useCallback } from 'react';
const SURVEY_STORAGE_KEY = 'customer_survey_draft';
// Survey configuration
const SURVEY_CONFIG = {
title: 'Customer Satisfaction Survey',
description: 'Help us improve our service by answering a few questions',
questions: [
{
id: 'satisfaction',
type: 'radio',
label: 'How satisfied are you with our service?',
required: true,
options: [
'Very Satisfied',
'Satisfied',
'Neutral',
'Dissatisfied',
'Very Dissatisfied',
],
},
{
id: 'recommendation',
type: 'rating',
label: 'How likely are you to recommend us? (0-10)',
required: true,
min: 0,
max: 10,
},
{
id: 'followup',
type: 'radio',
label: 'Would you like us to follow up with you?',
required: true,
options: ['Yes', 'No'],
// Conditional: Show next question only if Yes
},
{
id: 'contactMethod',
type: 'radio',
label: 'Preferred contact method',
required: true,
options: ['Email', 'Phone', 'SMS'],
// Show only if followup === 'Yes'
condition: (formValues) => formValues.followup === 'Yes',
},
{
id: 'features',
type: 'checkbox',
label: 'Which features do you use? (Select all that apply)',
required: true,
options: ['Dashboard', 'Reports', 'API', 'Mobile App', 'Integrations'],
},
{
id: 'improvements',
type: 'textarea',
label: 'What could we improve?',
required: true,
minLength: 20,
placeholder: 'Please provide detailed feedback (min 20 characters)...',
},
{
id: 'additionalComments',
type: 'textarea',
label: 'Any additional comments?',
required: false,
placeholder: 'Optional',
},
],
};
function CustomerSurvey() {
const [completedSurvey, setCompletedSurvey] = useState(null);
const [isSaving, setIsSaving] = useState(false);
// Load saved draft
const loadDraft = useCallback(() => {
try {
const draft = localStorage.getItem(SURVEY_STORAGE_KEY);
return draft ? JSON.parse(draft) : {};
} catch (error) {
console.error('Error loading draft:', error);
return {};
}
}, []);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
reset,
} = useForm({
mode: 'onTouched',
defaultValues: loadDraft(),
});
const formValues = watch();
// Auto-save to localStorage (debounced)
useEffect(() => {
setIsSaving(true);
const timeoutId = setTimeout(() => {
try {
localStorage.setItem(SURVEY_STORAGE_KEY, JSON.stringify(formValues));
setIsSaving(false);
} catch (error) {
console.error('Error saving draft:', error);
setIsSaving(false);
}
}, 1000);
return () => clearTimeout(timeoutId);
}, [formValues]);
// Calculate progress
const progress = useMemo(() => {
const totalQuestions = SURVEY_CONFIG.questions.filter(
(q) => !q.condition || q.condition(formValues),
).length;
const answeredQuestions = SURVEY_CONFIG.questions.filter((q) => {
if (q.condition && !q.condition(formValues)) return false;
const value = formValues[q.id];
if (Array.isArray(value)) return value.length > 0;
return value !== undefined && value !== '';
}).length;
return Math.round((answeredQuestions / totalQuestions) * 100);
}, [formValues]);
const onSubmit = async (data) => {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log('Survey submitted:', data);
setCompletedSurvey(data);
// Clear draft
localStorage.removeItem(SURVEY_STORAGE_KEY);
};
const clearDraft = useCallback(() => {
if (window.confirm('Clear all answers and start over?')) {
localStorage.removeItem(SURVEY_STORAGE_KEY);
reset({});
setCompletedSurvey(null);
}
}, [reset]);
const exportResults = useCallback(() => {
if (!completedSurvey) return;
const dataStr = JSON.stringify(completedSurvey, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `survey_results_${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
}, [completedSurvey]);
// If survey completed, show results
if (completedSurvey) {
return (
<SurveyResults
data={completedSurvey}
onExport={exportResults}
onStartNew={() => {
setCompletedSurvey(null);
reset({});
}}
/>
);
}
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
{/* Header */}
<header style={{ marginBottom: '32px' }}>
<h1>{SURVEY_CONFIG.title}</h1>
<p style={{ color: '#666' }}>{SURVEY_CONFIG.description}</p>
{/* Progress Bar */}
<div style={{ marginTop: '16px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '4px',
}}
>
<span style={{ fontSize: '14px', fontWeight: 'bold' }}>
Progress: {progress}%
</span>
<span style={{ fontSize: '14px', color: '#666' }}>
{isSaving ? '💾 Saving...' : '✓ Saved'}
</span>
</div>
<div
style={{
width: '100%',
height: '8px',
backgroundColor: '#e0e0e0',
borderRadius: '4px',
overflow: 'hidden',
}}
>
<div
style={{
width: `${progress}%`,
height: '100%',
backgroundColor: progress === 100 ? '#4caf50' : '#2196f3',
transition: 'width 0.3s ease',
}}
/>
</div>
</div>
</header>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)}>
{SURVEY_CONFIG.questions.map((question, index) => {
// Check condition
if (question.condition && !question.condition(formValues)) {
return null;
}
return (
<QuestionField
key={question.id}
question={question}
index={index}
register={register}
errors={errors}
watch={watch}
/>
);
})}
{/* Actions */}
<div
style={{
marginTop: '32px',
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
}}
>
<button
type='submit'
disabled={isSubmitting || progress < 100}
style={{
flex: 1,
minWidth: '200px',
padding: '16px',
backgroundColor:
isSubmitting || progress < 100 ? '#ccc' : '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
fontWeight: 'bold',
cursor:
isSubmitting || progress < 100 ? 'not-allowed' : 'pointer',
}}
>
{isSubmitting ? 'Submitting...' : 'Submit Survey'}
</button>
<button
type='button'
onClick={clearDraft}
style={{
padding: '16px 24px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Clear All
</button>
</div>
</form>
{/* A11y Info */}
<div
style={{
marginTop: '40px',
padding: '16px',
backgroundColor: '#e3f2fd',
borderRadius: '8px',
fontSize: '14px',
}}
>
<strong>♿ Accessibility:</strong>
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
<li>Use Tab to navigate between fields</li>
<li>Use Space/Enter to select radio/checkbox</li>
<li>All fields have proper labels</li>
<li>Errors announced to screen readers</li>
</ul>
</div>
</div>
);
}
// Question Field Component
const QuestionField = ({ question, index, register, errors, watch }) => {
const fieldStyles = {
container: {
marginBottom: '32px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
border: errors[question.id] ? '2px solid #f44336' : '1px solid #e0e0e0',
},
label: {
display: 'block',
marginBottom: '12px',
fontSize: '16px',
fontWeight: 'bold',
},
error: {
display: 'block',
color: '#f44336',
fontSize: '14px',
marginTop: '4px',
},
};
const renderField = () => {
switch (question.type) {
case 'radio':
return (
<div
role='radiogroup'
aria-labelledby={`question-${question.id}`}
>
{question.options.map((option) => (
<label
key={option}
style={{
display: 'block',
padding: '12px',
marginBottom: '8px',
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
}}
>
<input
type='radio'
value={option}
{...register(question.id, {
required:
question.required && `${question.label} is required`,
})}
style={{ marginRight: '8px' }}
/>
{option}
</label>
))}
</div>
);
case 'checkbox':
return (
<div
role='group'
aria-labelledby={`question-${question.id}`}
>
{question.options.map((option) => (
<label
key={option}
style={{
display: 'block',
padding: '12px',
marginBottom: '8px',
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
}}
>
<input
type='checkbox'
value={option}
{...register(question.id, {
validate: question.required
? (value) =>
(value && value.length > 0) ||
'Select at least one option'
: undefined,
})}
style={{ marginRight: '8px' }}
/>
{option}
</label>
))}
</div>
);
case 'rating':
return (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Array.from({ length: question.max + 1 }, (_, i) => i).map(
(num) => (
<label
key={num}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
backgroundColor:
watch(question.id) == num ? '#2196f3' : 'white',
color: watch(question.id) == num ? 'white' : 'black',
border: '2px solid #2196f3',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
<input
type='radio'
value={num}
{...register(question.id, {
required: question.required && 'Please select a rating',
})}
style={{ display: 'none' }}
/>
{num}
</label>
),
)}
</div>
);
case 'textarea':
const currentLength = watch(question.id)?.length || 0;
return (
<div>
<textarea
{...register(question.id, {
required: question.required && `${question.label} is required`,
minLength: question.minLength && {
value: question.minLength,
message: `Minimum ${question.minLength} characters required`,
},
})}
placeholder={question.placeholder}
rows={4}
style={{
width: '100%',
padding: '12px',
border: '1px solid #ccc',
borderRadius: '4px',
fontFamily: 'inherit',
fontSize: '14px',
resize: 'vertical',
}}
aria-describedby={
question.minLength ? `${question.id}-hint` : undefined
}
/>
{question.minLength && (
<div
id={`${question.id}-hint`}
style={{
marginTop: '4px',
fontSize: '14px',
color:
currentLength < question.minLength ? '#f44336' : '#666',
}}
>
{currentLength} / {question.minLength} characters
</div>
)}
</div>
);
default:
return (
<input
type='text'
{...register(question.id, {
required: question.required && `${question.label} is required`,
})}
style={{
width: '100%',
padding: '12px',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '14px',
}}
/>
);
}
};
return (
<div style={fieldStyles.container}>
<label
id={`question-${question.id}`}
style={fieldStyles.label}
>
{index + 1}. {question.label}
{question.required && <span style={{ color: '#f44336' }}> *</span>}
</label>
{renderField()}
{errors[question.id] && (
<span
style={fieldStyles.error}
role='alert'
aria-live='polite'
>
{errors[question.id].message}
</span>
)}
</div>
);
};
// Results Summary Component
function SurveyResults({ data, onExport, onStartNew }) {
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<div
style={{
padding: '24px',
backgroundColor: '#e8f5e9',
border: '2px solid #4caf50',
borderRadius: '8px',
marginBottom: '24px',
textAlign: 'center',
}}
>
<h1 style={{ color: '#2e7d32', margin: '0 0 8px 0' }}>
✅ Survey Completed!
</h1>
<p style={{ margin: 0, color: '#666' }}>Thank you for your feedback</p>
</div>
<div
style={{
padding: '24px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
marginBottom: '24px',
}}
>
<h2>Your Responses:</h2>
<pre
style={{
backgroundColor: '#fff',
padding: '16px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '14px',
border: '1px solid #e0e0e0',
}}
>
{JSON.stringify(data, null, 2)}
</pre>
</div>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<button
onClick={onExport}
style={{
flex: 1,
minWidth: '200px',
padding: '16px',
backgroundColor: '#2196f3',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer',
}}
>
📥 Export Results (JSON)
</button>
<button
onClick={onStartNew}
style={{
padding: '16px 24px',
backgroundColor: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Start New Survey
</button>
</div>
</div>
);
}
/*
Production Checklist:
✅ Validation
- All required fields validated
- Min/max constraints
- Conditional validation
- Custom validation rules
✅ Error Handling
- Form validation errors
- localStorage errors (try-catch)
- Edge cases handled
✅ Loading/Empty States
- Submitting state
- Saving indicator
- Completed state
- Empty form state
✅ Accessibility
- Keyboard navigation (Tab, Space, Enter)
- ARIA labels (role, aria-labelledby, aria-describedby)
- Focus management
- Error announcements (role="alert", aria-live="polite")
- Semantic HTML
✅ Performance
- useMemo for progress calculation
- useCallback for handlers
- Debounced auto-save
- Optimized re-renders
✅ Features
- Auto-save to localStorage
- Progress tracking
- Conditional questions
- Export functionality
- Clear/reset
- Multiple question types
✅ UX
- Visual progress bar
- Save indicator
- Character counter
- Clear error messages
- Success state
✅ Mobile Responsive
- Flexible layouts (flex, grid)
- Readable font sizes
- Touch-friendly buttons
- Wrapping elements
*/📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: Manual Forms vs React Hook Form
| Aspect | Manual (useState) | React Hook Form | Winner |
|---|---|---|---|
| Boilerplate Code | Cao (mỗi field = 3 states) | Thấp (1 hook cho tất cả) | ✅ RHF |
| Performance | ❌ Re-render mỗi keystroke | ✅ Minimal re-renders | ✅ RHF |
| Bundle Size | 0KB (built-in) | ~8KB | Manual |
| Validation | Phải tự viết | Built-in rules | ✅ RHF |
| Learning Curve | Dễ hiểu | Cần học API | Manual |
| TypeScript Support | Phải tự type | Full type safety | ✅ RHF |
| Scalability | Khó với large forms | Dễ dàng scale | ✅ RHF |
| Form Arrays | Phức tạp | useFieldArray built-in | ✅ RHF |
| Async Validation | Phải tự handle | Built-in support | ✅ RHF |
| Integration | N/A | Dễ với Zod/Yup | ✅ RHF |
Khi nào dùng Manual Form?
✅ Use Manual khi:
- Form rất đơn giản (1-2 fields)
- Không cần validation phức tạp
- Không muốn thêm dependency
- Learning project (hiểu React basics)
Ví dụ:
// Simple search box
const [query, setQuery] = useState('');
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>;Khi nào dùng React Hook Form?
✅ Use RHF khi:
- Form trung bình đến lớn (3+ fields)
- Cần validation phức tạp
- Performance quan trọng
- Production code
- Cần form arrays
- Integration với schema validation
Ví dụ:
- Registration forms
- Survey forms
- Checkout forms
- Profile settings
- Job applications
Decision Tree
START: Cần tạo form
↓
Simple form (1-2 fields)?
├─ YES → Manual (useState)
└─ NO ↓
Cần validation phức tạp?
├─ YES → React Hook Form
└─ NO ↓
Performance critical?
├─ YES → React Hook Form
└─ NO ↓
Form có arrays/dynamic fields?
├─ YES → React Hook Form
└─ NO ↓
3+ fields?
├─ YES → React Hook Form
└─ NO → Manual OK (but consider RHF for consistency)🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Validation không hoạt động ❌
// ❌ Code bị lỗi
function BuggyForm() {
const { register, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input
{...register('email')}
required // ← BUG: HTML validation, not RHF!
/>
<button>Submit</button>
</form>
);
}
// ❓ Câu hỏi: Tại sao form vẫn submit khi email empty?💡 Giải thích & Fix
Vấn đề:
- HTML
requiredattribute KHÔNG phải RHF validation - RHF cần validation rules trong
register() - HTML validation có thể bypass (user disable)
Fix:
// ✅ Cách đúng
<input
{...register('email', {
required: 'Email is required', // ← RHF validation
})}
// KHÔNG dùng HTML required
/>;
{
errors.email && <span>{errors.email.message}</span>;
}Nguyên tắc:
- Luôn validate bằng RHF, không dựa vào HTML validation
- HTML validation = UX enhancement, không phải security
Bug 2: Form không reset sau submit ❌
// ❌ Code bị lỗi
function BuggyForm() {
const { register, handleSubmit } = useForm({
defaultValues: { name: '', email: '' },
});
const onSubmit = (data) => {
console.log(data);
// ← BUG: Không reset!
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
<input {...register('email')} />
<button>Submit</button>
</form>
);
}
// ❓ Sau khi submit, form vẫn giữ giá trị cũ. Tại sao?💡 Giải thích & Fix
Vấn đề:
- RHF KHÔNG tự động reset sau submit
- Phải gọi
reset()manually - Hoặc config
resetOptionstrong handleSubmit
Fix 1: Manual reset
// ✅ Cách 1: Gọi reset() sau submit
const { register, handleSubmit, reset } = useForm({
defaultValues: { name: '', email: '' },
});
const onSubmit = (data) => {
console.log(data);
reset(); // ← Reset về defaultValues
};Fix 2: Reset về custom values
// ✅ Cách 2: Reset về values khác
reset({ name: 'John', email: '' }); // Custom valuesNguyên tắc:
- Form state persist sau submit (by design)
- Luôn gọi
reset()nếu cần clear form
Bug 3: Watch gây infinite re-renders ❌
// ❌ Code bị lỗi
function BuggyForm() {
const { register, watch } = useForm();
// BUG: watch() mỗi render tạo subscription mới!
const allValues = watch();
useEffect(() => {
console.log('Values changed:', allValues);
}, [allValues]); // ← Infinite loop!
return (
<form>
<input {...register('name')} />
</form>
);
}
// ❓ Component bị infinite re-render. Tại sao?💡 Giải thích & Fix
Vấn đề:
watch()trả về object mới mỗi render- Object mới →
allValuesthay đổi reference - useEffect trigger → re-render → watch() tạo object mới → loop!
Fix 1: Watch specific fields
// ✅ Cách 1: Watch specific fields (recommended)
const name = watch('name');
useEffect(() => {
console.log('Name changed:', name);
}, [name]); // Primitive value, stable comparisonFix 2: Use callback form
// ✅ Cách 2: Callback form (no re-render)
useEffect(() => {
const subscription = watch((value, { name, type }) => {
console.log(value, name, type);
});
return () => subscription.unsubscribe();
}, [watch]);Fix 3: Memoize if needed
// ✅ Cách 3: Memoize (last resort)
const allValues = watch();
useEffect(() => {
// Only log when specific field changes
console.log('Name:', allValues.name);
}, [allValues.name]); // Specific fieldNguyên tắc:
- Watch specific fields > watch all
- Use callback form cho subscriptions
- Avoid watching objects in dependencies
✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
- [ ] Hiểu sự khác biệt giữa controlled vs uncontrolled components
- [ ] Biết khi nào dùng React Hook Form vs manual useState
- [ ] Nắm vững
register(),handleSubmit(),formState - [ ] Hiểu built-in validation rules (required, pattern, min/max, validate)
- [ ] Biết cách validate cross-field (password confirmation)
- [ ] Hiểu
watch()để theo dõi form values - [ ] Biết cách
reset()form - [ ] Hiểu
setValue()để set value programmatically - [ ] Nắm được validation modes (onChange, onBlur, onSubmit, onTouched)
- [ ] Hiểu performance benefits của RHF
Code Review Checklist
Form Setup:
- [ ] useForm với proper mode (onBlur/onTouched recommended)
- [ ] defaultValues được định nghĩa rõ ràng
- [ ] Destructure cần thiết từ useForm (register, handleSubmit, formState, etc.)
Field Registration:
- [ ] Tất cả inputs đều có
{...register('fieldName')} - [ ] Validation rules đầy đủ (required, pattern, etc.)
- [ ] Error messages rõ ràng, user-friendly
- [ ] Unique name cho mỗi field
Error Handling:
- [ ] Hiển thị errors từ
formState.errors - [ ] Errors có styling riêng (color, border)
- [ ] Error messages có accessibility (role="alert")
Submit Handling:
- [ ]
onSubmitvớihandleSubmit() - [ ] Handle loading state (
isSubmitting) - [ ] Reset form sau successful submit (if needed)
- [ ] Handle submit errors properly
UX:
- [ ] Disable submit khi invalid (if appropriate)
- [ ] Loading indicator khi submitting
- [ ] Success feedback sau submit
- [ ] Clear error messages
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Profile Update Form
Tạo form cập nhật profile với:
- Fields: avatar URL, display name, bio, location, website
- Validation: URL format cho avatar/website, max length cho bio
- Preview avatar khi nhập URL
- Character counter cho bio (max 200)
- Save button disabled khi không có thay đổi (use
formState.isDirty)
Nâng cao (60 phút)
Multi-Currency Calculator Form
Tạo form chuyển đổi tiền tệ với:
- Amount input
- From currency (select)
- To currency (select)
- Exchange rate (auto-fetch hoặc manual)
- Result display
- History (lưu 5 conversions gần nhất vào localStorage)
- Clear history button
- Validation: amount > 0, currencies khác nhau
📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Hook Form Official Docs
- https://react-hook-form.com/get-started
- Đọc: Get Started, API Reference (useForm, register, handleSubmit)
Controlled vs Uncontrolled Components
Đọc thêm
React Hook Form - Advanced
- https://react-hook-form.com/advanced-usage
- useFieldArray, Custom hooks
Form Validation Best Practices
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (Cần biết trước)
- Ngày 13: Forms với Controlled Components (useState)
- Ngày 24: Custom Hooks (để hiểu RHF internals)
- Ngày 32-34: Performance (để hiểu RHF benefits)
Hướng tới (Sẽ dùng sau)
- Ngày 42: React Hook Form Advanced (useFieldArray, watch patterns)
- Ngày 43: Schema Validation với Zod
- Ngày 44: Multi-step Forms
- Ngày 45: Final Project - Registration Flow
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Validation Strategy
// ❌ Tránh: Validate mọi keystroke (bad UX)
mode: 'onChange' // Too aggressive
// ✅ Tốt hơn: Validate sau blur đầu tiên
mode: 'onTouched' // Or 'onBlur'
// Production tip: Combine strategies
mode: 'onTouched', // First validation on blur
reValidateMode: 'onChange' // Then real-time after error2. Performance Optimization
// ❌ Tránh: Watch toàn bộ form
const allValues = watch(); // Creates new object every render
// ✅ Tốt hơn: Watch specific fields
const email = watch('email');
const password = watch('password');
// ✅ Hoặc dùng callback
useEffect(() => {
const { unsubscribe } = watch((data) => {
// Handle change
});
return () => unsubscribe();
}, [watch]);3. Error Handling
// Production pattern: Centralized error display
const onError = (errors) => {
// Log to monitoring service
console.error('Form errors:', errors);
// Show toast notification
toast.error('Please fix form errors');
// Scroll to first error
const firstError = Object.keys(errors)[0];
document.querySelector(`[name="${firstError}"]`)?.focus();
};
<form onSubmit={handleSubmit(onSubmit, onError)}>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 state control value (useState). Uncontrolled: DOM controls value (refs). RHF uses uncontrolled approach for better performance.
Q: Làm sao validate form với React Hook Form? A: Dùng validation rules trong
register(): required, pattern, min/max, validate function.
Mid Level: 3. Q: Tại sao React Hook Form performance tốt hơn controlled forms? A: RHF dùng refs thay vì state, không trigger re-render mỗi keystroke. Chỉ re-render khi validation errors thay đổi.
- Q: Làm sao handle conditional validation? A: Dùng
validatefunction với access đến other field values quawatch().
Senior Level: 5. Q: Design form system cho large application với nhiều loại forms khác nhau? A:
- Shared validation rules (custom hooks)
- Reusable field components
- Form config driven approach
- Integration với API error handling
- Centralized error display
- Analytics tracking
- Q: Trade-offs giữa React Hook Form vs Formik? A:
- RHF: Better performance (uncontrolled), smaller bundle, modern API
- Formik: More mature, larger ecosystem, familiar to many devs
- Choice depends on: team familiarity, bundle size constraints, existing codebase
War Stories
Story 1: The Registration Form Nightmare
Situation: Registration form với 15 fields, mỗi field có useState.
Problem: Type vào email → toàn bộ form re-render → lag!
Solution: Migrate sang RHF → Performance improvement 10x.
Lesson: Controlled forms không scale cho large forms.Story 2: The Lost Data
Situation: User điền form 10 phút, case browser crash.
Problem: Tất cả data mất vì không auto-save.
Solution: Auto-save vào localStorage mỗi 5s với RHF watch().
Lesson: Always implement draft saves cho long forms.Story 3: The Validation Hell
Situation: Password validation với 8 rules khác nhau.
Problem: 8 validation functions, error handling phức tạp.
Solution: Dùng validate object trong RHF với named rules.
Lesson: RHF's validate object làm code clean hơn nhiều.🎯 PREVIEW NGÀY MAI
Ngày 42: React Hook Form - Advanced
Chúng ta sẽ học:
useFieldArray- Quản lý dynamic form arraysuseFormContext- Share form state across componentsuseWatch- Optimized field watching- Advanced validation patterns
- Custom field components
- Form wizard patterns
- Error recovery strategies
Chuẩn bị:
- Ôn lại bài hôm nay
- Làm bài tập về nhà
- Suy nghĩ về use cases cần dynamic fields
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 41!