📅 NGÀY 44: Multi-step Forms - Wizard Pattern
Tóm tắt: Hôm nay chúng ta học cách xây dựng multi-step forms (form wizard) - pattern phổ biến trong e-commerce checkout, onboarding flows, và registration processes. Chúng ta sẽ kết hợp React Hook Form, Zod, và Context API để quản lý state phức tạp, validate từng step, persist data giữa các steps, và xử lý navigation. Đây là ngày tổng hợp mọi kiến thức forms đã học để build production-ready wizards.
🎯 Mục tiêu học tập (5 phút)
- [ ] Hiểu được wizard pattern là gì và khi nào nên dùng
- [ ] Implement step navigation với validation (không cho next nếu current step invalid)
- [ ] Quản lý form state across multiple steps với Context
- [ ] Validate partial data per step với Zod schemas
- [ ] Persist form data khi user navigate giữa các steps
- [ ] Handle "Save & Continue Later" functionality
- [ ] Build progress indicator và step validation status
🤔 Kiểm tra đầu vào (5 phút)
Context API (Ngày 36-38): Làm sao share state giữa nhiều components không props drilling? Khi nào nên dùng Context vs lifting state up?
Zod Schemas (Ngày 43): Làm sao validate một phần của object? Ví dụ: object có 10 fields nhưng chỉ validate 3 fields đầu?
React Hook Form (Ngày 41-42):
watch()vàgetValues()khác nhau thế nào? Cái nào trigger re-render?
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Bạn đang build e-commerce checkout flow. Form có 15+ fields:
- Shipping: name, address, city, zip, country, phone (6 fields)
- Payment: card number, holder, expiry, cvv, billing address (5 fields)
- Review: order summary, terms, promo code (4 fields)
Vấn đề với single-page form:
// ❌ OVERWHELMING: 15 fields trên 1 trang
function CheckoutFormBad() {
const { register } = useForm();
return (
<form>
{/* Shipping - scroll scroll scroll */}
<input {...register('name')} />
<input {...register('address')} />
<input {...register('city')} />
<input {...register('zip')} />
<input {...register('country')} />
<input {...register('phone')} />
{/* Payment - user đã quên đang ở đâu */}
<input {...register('cardNumber')} />
<input {...register('cardHolder')} />
<input {...register('expiryDate')} />
<input {...register('cvv')} />
<input {...register('billingAddress')} />
{/* Review - quá dài, user bỏ cuộc */}
<textarea {...register('notes')} />
<input
type='checkbox'
{...register('terms')}
/>
<input {...register('promoCode')} />
<button type='submit'>Place Order</button>
</form>
);
}Hậu quả:
- 📉 Form abandonment rate cao (users overwhelmed)
- 😵 Cognitive overload - quá nhiều fields cùng lúc
- 🐛 Validation errors khó locate - scroll tìm lỗi ở đâu?
- 📱 Mobile UX tệ - scroll mãi mới đến submit button
- ❌ Không thể "save progress" - mất data nếu refresh
1.2 Giải Pháp: Multi-step Form (Wizard Pattern)
Chia form thành các bước nhỏ, mỗi bước tập trung vào 1 nhóm thông tin:
// ✅ BETTER: Chia thành 3 steps
Step 1: Shipping Info (6 fields)
↓ Validate → Next
Step 2: Payment Info (5 fields)
↓ Validate → Next
Step 3: Review & Confirm (4 fields)
↓ Submit
// Benefits:
// ✅ Dễ tiêu hóa - chỉ 5-6 fields mỗi lần
// ✅ Progress indication - user biết còn bao nhiêu bước
// ✅ Validation per step - sửa lỗi ngay, không đợi đến cuối
// ✅ Save progress - có thể back/forward
// ✅ Better mobile UX1.3 Mental Model
┌─────────────────────────────────────────────────────────────┐
│ WIZARD PATTERN ARCHITECTURE │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ WIZARD CONTEXT │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ State: │ │
│ │ - currentStep: number │ │
│ │ - formData: { step1: {...}, step2: {...}, ... } │ │
│ │ - completedSteps: Set<number> │ │
│ │ - isStepValid: Map<number, boolean> │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Actions: │ │
│ │ - nextStep() │ │
│ │ - previousStep() │ │
│ │ - goToStep(n) │ │
│ │ - updateStepData(step, data) │ │
│ │ - validateStep(step) │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Step 1 │───────▶│ Step 2 │───────▶│ Step 3 │
│ Shipping │ │ Payment │ │ Review │
└──────────┘ └──────────┘ └──────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Schema │ │ Schema │ │ Schema │
│ (Zod) │ │ (Zod) │ │ (Zod) │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└───────────────────┴───────────────────┘
│
▼
┌──────────────┐
│ Final Submit │
└──────────────┘
FLOW:
1. User fills Step 1 → Validate → Save to context
2. Move to Step 2 → Pre-fill from context → Validate → Save
3. Move to Step 3 → Review all data → Submit
4. Can go back to edit → Data persistedAnalogy: Wizard form như hành trình đi tàu
- Mỗi step = 1 ga tàu
- Phải hoàn thành thủ tục ở ga này mới lên tàu đến ga tiếp theo (validation)
- Có thể quay lại ga trước để sửa thông tin (navigation)
- Hành lý (data) theo bạn suốt hành trình (persistence)
- Biết mình đang ở ga nào và còn bao nhiêu ga nữa (progress indicator)
1.4 Hiểu Lầm Phổ Biến
❌ "Multi-step form = nhiều <form> tags"
- ✅ Thường chỉ cần 1
<form>tag, navigate bằng conditional rendering
❌ "Phải validate tất cả fields mỗi step"
- ✅ Chỉ validate fields của step hiện tại, final step mới validate all
❌ "Data bị mất khi chuyển step"
- ✅ Dùng Context/State management để persist data across steps
❌ "Wizard pattern chỉ cho checkout"
- ✅ Dùng cho: onboarding, surveys, account setup, job applications, complex configs...
❌ "Không thể edit previous steps"
- ✅ Best practice: cho phép back để edit, nhưng phải re-validate
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Basic Wizard với useState ⭐
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* Demo: Simple 3-step wizard với basic state management
* Use case: User registration wizard
*/
// Step schemas
const step1Schema = z.object({
firstName: z.string().min(2, "First name required"),
lastName: z.string().min(2, "Last name required"),
email: z.string().email("Invalid email")
});
const step2Schema = z.object({
password: z.string().min(8, "Password must be 8+ chars"),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"]
});
const step3Schema = z.object({
acceptTerms: z.boolean().refine(val => val === true, {
message: "Must accept terms"
})
});
type Step1Data = z.infer<typeof step1Schema>;
type Step2Data = z.infer<typeof step2Schema>;
type Step3Data = z.infer<typeof step3Schema>;
/**
* Step 1: Basic Info
*/
function Step1({ onNext, defaultValues }: {
onNext: (data: Step1Data) => void;
defaultValues?: Partial<Step1Data>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<Step1Data>({
resolver: zodResolver(step1Schema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Step 1: Basic Info</h2>
<div>
<input {...register("firstName")} placeholder="First Name" />
{errors.firstName && <p style={{ color: 'red' }}>{errors.firstName.message}</p>}
</div>
<div>
<input {...register("lastName")} placeholder="Last Name" />
{errors.lastName && <p style={{ color: 'red' }}>{errors.lastName.message}</p>}
</div>
<div>
<input {...register("email")} placeholder="Email" />
{errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
</div>
<button type="submit">Next →</button>
</form>
);
}
/**
* Step 2: Password
*/
function Step2({ onNext, onBack, defaultValues }: {
onNext: (data: Step2Data) => void;
onBack: () => void;
defaultValues?: Partial<Step2Data>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<Step2Data>({
resolver: zodResolver(step2Schema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Step 2: Set Password</h2>
<div>
<input type="password" {...register("password")} placeholder="Password" />
{errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
</div>
<div>
<input type="password" {...register("confirmPassword")} placeholder="Confirm Password" />
{errors.confirmPassword && <p style={{ color: 'red' }}>{errors.confirmPassword.message}</p>}
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
/**
* Step 3: Confirm
*/
function Step3({ onSubmit, onBack, data }: {
onSubmit: (data: Step3Data) => void;
onBack: () => void;
data: { step1: Step1Data; step2: Step2Data };
}) {
const { register, handleSubmit, formState: { errors } } = useForm<Step3Data>({
resolver: zodResolver(step3Schema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Step 3: Review & Confirm</h2>
<div>
<h3>Your Information:</h3>
<p>Name: {data.step1.firstName} {data.step1.lastName}</p>
<p>Email: {data.step1.email}</p>
<p>Password: ******** (set)</p>
</div>
<div>
<label>
<input type="checkbox" {...register("acceptTerms")} />
I accept the terms and conditions
</label>
{errors.acceptTerms && <p style={{ color: 'red' }}>{errors.acceptTerms.message}</p>}
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Create Account</button>
</div>
</form>
);
}
/**
* Main Wizard Component
*/
function BasicWizard() {
const [currentStep, setCurrentStep] = useState(1);
const [step1Data, setStep1Data] = useState<Step1Data | null>(null);
const [step2Data, setStep2Data] = useState<Step2Data | null>(null);
const handleStep1Submit = (data: Step1Data) => {
setStep1Data(data);
setCurrentStep(2);
};
const handleStep2Submit = (data: Step2Data) => {
setStep2Data(data);
setCurrentStep(3);
};
const handleFinalSubmit = (data: Step3Data) => {
const finalData = {
...step1Data!,
...step2Data!,
...data
};
console.log('Registration complete:', finalData);
alert('Account created successfully!');
};
return (
<div>
{/* Progress Indicator */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
<span style={{ fontWeight: currentStep === 1 ? 'bold' : 'normal' }}>
1. Info
</span>
<span style={{ fontWeight: currentStep === 2 ? 'bold' : 'normal' }}>
2. Password
</span>
<span style={{ fontWeight: currentStep === 3 ? 'bold' : 'normal' }}>
3. Confirm
</span>
</div>
{/* Step Content */}
{currentStep === 1 && (
<Step1
onNext={handleStep1Submit}
defaultValues={step1Data || undefined}
/>
)}
{currentStep === 2 && (
<Step2
onNext={handleStep2Submit}
onBack={() => setCurrentStep(1)}
defaultValues={step2Data || undefined}
/>
)}
{currentStep === 3 && step1Data && step2Data && (
<Step3
onSubmit={handleFinalSubmit}
onBack={() => setCurrentStep(2)}
data={{ step1: step1Data, step2: step2Data }}
/>
)}
</div>
);
}
// 🎯 KEY CONCEPTS:
// 1. Mỗi step có schema riêng
// 2. Data được save vào state khi next
// 3. defaultValues để pre-fill khi back
// 4. Progress indicator visual feedbackDemo 2: Wizard với Context API ⭐⭐
import { createContext, useContext, useReducer, ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* Demo: Scalable wizard với Context + useReducer
* Use case: Complex multi-step form với shared state
*/
// ============================================
// CONTEXT SETUP
// ============================================
type WizardState = {
currentStep: number;
totalSteps: number;
formData: Record<string, any>;
completedSteps: Set<number>;
};
type WizardAction =
| { type: 'NEXT_STEP' }
| { type: 'PREVIOUS_STEP' }
| { type: 'GO_TO_STEP'; payload: number }
| { type: 'UPDATE_STEP_DATA'; payload: { step: number; data: any } }
| { type: 'MARK_STEP_COMPLETE'; payload: number }
| { type: 'RESET_WIZARD' };
const wizardReducer = (state: WizardState, action: WizardAction): WizardState => {
switch (action.type) {
case 'NEXT_STEP':
return {
...state,
currentStep: Math.min(state.currentStep + 1, state.totalSteps)
};
case 'PREVIOUS_STEP':
return {
...state,
currentStep: Math.max(state.currentStep - 1, 1)
};
case 'GO_TO_STEP':
// Only allow going to completed steps or next step
if (action.payload <= state.currentStep ||
state.completedSteps.has(action.payload - 1)) {
return { ...state, currentStep: action.payload };
}
return state;
case 'UPDATE_STEP_DATA':
return {
...state,
formData: {
...state.formData,
[`step${action.payload.step}`]: action.payload.data
}
};
case 'MARK_STEP_COMPLETE':
const newCompleted = new Set(state.completedSteps);
newCompleted.add(action.payload);
return { ...state, completedSteps: newCompleted };
case 'RESET_WIZARD':
return {
currentStep: 1,
totalSteps: state.totalSteps,
formData: {},
completedSteps: new Set()
};
default:
return state;
}
};
const WizardContext = createContext<{
state: WizardState;
dispatch: React.Dispatch<WizardAction>;
} | null>(null);
/**
* Wizard Provider
*/
function WizardProvider({ children, totalSteps }: {
children: ReactNode;
totalSteps: number;
}) {
const [state, dispatch] = useReducer(wizardReducer, {
currentStep: 1,
totalSteps,
formData: {},
completedSteps: new Set()
});
return (
<WizardContext.Provider value={{ state, dispatch }}>
{children}
</WizardContext.Provider>
);
}
/**
* Custom hook to use wizard context
*/
function useWizard() {
const context = useContext(WizardContext);
if (!context) {
throw new Error('useWizard must be used within WizardProvider');
}
return context;
}
// ============================================
// STEP COMPONENTS
// ============================================
const personalInfoSchema = z.object({
fullName: z.string().min(2, "Name required"),
age: z.string()
.transform(val => parseInt(val, 10))
.pipe(z.number().min(18, "Must be 18+"))
});
type PersonalInfoData = z.infer<typeof personalInfoSchema>;
/**
* Step 1: Personal Info
*/
function PersonalInfoStep() {
const { state, dispatch } = useWizard();
const defaultValues = state.formData.step1;
const { register, handleSubmit, formState: { errors } } = useForm<PersonalInfoData>({
resolver: zodResolver(personalInfoSchema),
defaultValues
});
const onSubmit = (data: PersonalInfoData) => {
dispatch({ type: 'UPDATE_STEP_DATA', payload: { step: 1, data } });
dispatch({ type: 'MARK_STEP_COMPLETE', payload: 1 });
dispatch({ type: 'NEXT_STEP' });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Personal Information</h2>
<div>
<input {...register("fullName")} placeholder="Full Name" />
{errors.fullName && <p style={{ color: 'red' }}>{errors.fullName.message}</p>}
</div>
<div>
<input {...register("age")} placeholder="Age" />
{errors.age && <p style={{ color: 'red' }}>{errors.age.message}</p>}
</div>
<button type="submit">Next →</button>
</form>
);
}
const contactInfoSchema = z.object({
email: z.string().email("Invalid email"),
phone: z.string().regex(/^\d{10}$/, "Phone must be 10 digits")
});
type ContactInfoData = z.infer<typeof contactInfoSchema>;
/**
* Step 2: Contact Info
*/
function ContactInfoStep() {
const { state, dispatch } = useWizard();
const defaultValues = state.formData.step2;
const { register, handleSubmit, formState: { errors } } = useForm<ContactInfoData>({
resolver: zodResolver(contactInfoSchema),
defaultValues
});
const onSubmit = (data: ContactInfoData) => {
dispatch({ type: 'UPDATE_STEP_DATA', payload: { step: 2, data } });
dispatch({ type: 'MARK_STEP_COMPLETE', payload: 2 });
dispatch({ type: 'NEXT_STEP' });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Contact Information</h2>
<div>
<input {...register("email")} placeholder="Email" />
{errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
</div>
<div>
<input {...register("phone")} placeholder="Phone (10 digits)" />
{errors.phone && <p style={{ color: 'red' }}>{errors.phone.message}</p>}
</div>
<div>
<button type="button" onClick={() => dispatch({ type: 'PREVIOUS_STEP' })}>
← Back
</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
/**
* Step 3: Review
*/
function ReviewStep() {
const { state, dispatch } = useWizard();
const handleSubmit = () => {
const allData = {
...state.formData.step1,
...state.formData.step2
};
console.log('Wizard completed:', allData);
alert('Form submitted!');
dispatch({ type: 'RESET_WIZARD' });
};
return (
<div>
<h2>Review Your Information</h2>
<div>
<h3>Personal Info:</h3>
<p>Name: {state.formData.step1?.fullName}</p>
<p>Age: {state.formData.step1?.age}</p>
</div>
<div>
<h3>Contact Info:</h3>
<p>Email: {state.formData.step2?.email}</p>
<p>Phone: {state.formData.step2?.phone}</p>
</div>
<div>
<button type="button" onClick={() => dispatch({ type: 'PREVIOUS_STEP' })}>
← Back
</button>
<button onClick={handleSubmit}>Submit</button>
</div>
</div>
);
}
/**
* Progress Indicator Component
*/
function WizardProgress() {
const { state, dispatch } = useWizard();
const steps = [
{ number: 1, label: 'Personal' },
{ number: 2, label: 'Contact' },
{ number: 3, label: 'Review' }
];
return (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
{steps.map(step => (
<button
key={step.number}
onClick={() => dispatch({ type: 'GO_TO_STEP', payload: step.number })}
disabled={!state.completedSteps.has(step.number - 1) && step.number > state.currentStep}
style={{
fontWeight: state.currentStep === step.number ? 'bold' : 'normal',
opacity: state.completedSteps.has(step.number) ? 1 : 0.5
}}
>
{step.number}. {step.label}
{state.completedSteps.has(step.number) && ' ✓'}
</button>
))}
</div>
);
}
/**
* Main Wizard with Context
*/
function ContextWizard() {
return (
<WizardProvider totalSteps={3}>
<WizardProgress />
<WizardSteps />
</WizardProvider>
);
}
function WizardSteps() {
const { state } = useWizard();
return (
<>
{state.currentStep === 1 && <PersonalInfoStep />}
{state.currentStep === 2 && <ContactInfoStep />}
{state.currentStep === 3 && <ReviewStep />}
</>
);
}
// 🎯 BENEFITS OF CONTEXT APPROACH:
// 1. Centralized state management
// 2. Easy to add new steps
// 3. Can access wizard state from any component
// 4. Reducer pattern for complex state logic
// 5. Type-safe actionsDemo 3: Dynamic Steps với Conditional Logic ⭐⭐⭐
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* Demo: Dynamic wizard - steps change based on user input
* Use case: Survey where questions depend on previous answers
*/
// Step 1: Account Type
const accountTypeSchema = z.object({
accountType: z.enum(['personal', 'business'], {
errorMap: () => ({ message: "Please select account type" })
})
});
type AccountTypeData = z.infer<typeof accountTypeSchema>;
/**
* Step 1: Choose Account Type
*/
function AccountTypeStep({ onNext }: {
onNext: (data: AccountTypeData) => void;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<AccountTypeData>({
resolver: zodResolver(accountTypeSchema)
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Choose Account Type</h2>
<div>
<label>
<input type="radio" {...register("accountType")} value="personal" />
Personal Account
</label>
</div>
<div>
<label>
<input type="radio" {...register("accountType")} value="business" />
Business Account
</label>
</div>
{errors.accountType && <p style={{ color: 'red' }}>{errors.accountType.message}</p>}
<button type="submit">Next →</button>
</form>
);
}
// Personal Account Schema
const personalDetailsSchema = z.object({
fullName: z.string().min(2, "Name required"),
dateOfBirth: z.string().min(1, "Date of birth required")
});
type PersonalDetailsData = z.infer<typeof personalDetailsSchema>;
/**
* Step 2a: Personal Details (conditional)
*/
function PersonalDetailsStep({ onNext, onBack }: {
onNext: (data: PersonalDetailsData) => void;
onBack: () => void;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<PersonalDetailsData>({
resolver: zodResolver(personalDetailsSchema)
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Personal Details</h2>
<div>
<input {...register("fullName")} placeholder="Full Name" />
{errors.fullName && <p style={{ color: 'red' }}>{errors.fullName.message}</p>}
</div>
<div>
<input type="date" {...register("dateOfBirth")} />
{errors.dateOfBirth && <p style={{ color: 'red' }}>{errors.dateOfBirth.message}</p>}
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
// Business Account Schema
const businessDetailsSchema = z.object({
companyName: z.string().min(2, "Company name required"),
taxId: z.string().regex(/^\d{9}$/, "Tax ID must be 9 digits"),
employeeCount: z.string()
.transform(val => parseInt(val, 10))
.pipe(z.number().min(1, "Must have at least 1 employee"))
});
type BusinessDetailsData = z.infer<typeof businessDetailsSchema>;
/**
* Step 2b: Business Details (conditional)
*/
function BusinessDetailsStep({ onNext, onBack }: {
onNext: (data: BusinessDetailsData) => void;
onBack: () => void;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<BusinessDetailsData>({
resolver: zodResolver(businessDetailsSchema)
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Business Details</h2>
<div>
<input {...register("companyName")} placeholder="Company Name" />
{errors.companyName && <p style={{ color: 'red' }}>{errors.companyName.message}</p>}
</div>
<div>
<input {...register("taxId")} placeholder="Tax ID (9 digits)" />
{errors.taxId && <p style={{ color: 'red' }}>{errors.taxId.message}</p>}
</div>
<div>
<input {...register("employeeCount")} placeholder="Number of Employees" />
{errors.employeeCount && <p style={{ color: 'red' }}>{errors.employeeCount.message}</p>}
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
/**
* Final Review Step
*/
function FinalReviewStep({ data, onBack, onSubmit }: {
data: any;
onBack: () => void;
onSubmit: () => void;
}) {
return (
<div>
<h2>Review Your Information</h2>
<div>
<h3>Account Type:</h3>
<p>{data.step1.accountType}</p>
</div>
{data.step1.accountType === 'personal' && data.step2Personal && (
<div>
<h3>Personal Details:</h3>
<p>Name: {data.step2Personal.fullName}</p>
<p>DOB: {data.step2Personal.dateOfBirth}</p>
</div>
)}
{data.step1.accountType === 'business' && data.step2Business && (
<div>
<h3>Business Details:</h3>
<p>Company: {data.step2Business.companyName}</p>
<p>Tax ID: {data.step2Business.taxId}</p>
<p>Employees: {data.step2Business.employeeCount}</p>
</div>
)}
<div>
<button type="button" onClick={onBack}>← Back</button>
<button onClick={onSubmit}>Create Account</button>
</div>
</div>
);
}
/**
* Dynamic Wizard Main Component
*/
function DynamicWizard() {
const [currentStep, setCurrentStep] = useState(1);
const [accountTypeData, setAccountTypeData] = useState<AccountTypeData | null>(null);
const [personalData, setPersonalData] = useState<PersonalDetailsData | null>(null);
const [businessData, setBusinessData] = useState<BusinessDetailsData | null>(null);
const handleAccountTypeSubmit = (data: AccountTypeData) => {
setAccountTypeData(data);
setCurrentStep(2);
};
const handlePersonalSubmit = (data: PersonalDetailsData) => {
setPersonalData(data);
setCurrentStep(3);
};
const handleBusinessSubmit = (data: BusinessDetailsData) => {
setBusinessData(data);
setCurrentStep(3);
};
const handleFinalSubmit = () => {
const finalData = {
step1: accountTypeData,
step2Personal: personalData,
step2Business: businessData
};
console.log('Account created:', finalData);
alert('Account created successfully!');
};
return (
<div>
{/* Dynamic Progress Indicator */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
<span style={{ fontWeight: currentStep === 1 ? 'bold' : 'normal' }}>
1. Account Type
</span>
<span style={{ fontWeight: currentStep === 2 ? 'bold' : 'normal' }}>
2. {accountTypeData?.accountType === 'business' ? 'Business' : 'Personal'} Details
</span>
<span style={{ fontWeight: currentStep === 3 ? 'bold' : 'normal' }}>
3. Review
</span>
</div>
{/* Step 1: Account Type */}
{currentStep === 1 && (
<AccountTypeStep onNext={handleAccountTypeSubmit} />
)}
{/* Step 2: Conditional - Personal or Business */}
{currentStep === 2 && accountTypeData?.accountType === 'personal' && (
<PersonalDetailsStep
onNext={handlePersonalSubmit}
onBack={() => setCurrentStep(1)}
/>
)}
{currentStep === 2 && accountTypeData?.accountType === 'business' && (
<BusinessDetailsStep
onNext={handleBusinessSubmit}
onBack={() => setCurrentStep(1)}
/>
)}
{/* Step 3: Review */}
{currentStep === 3 && (
<FinalReviewStep
data={{
step1: accountTypeData,
step2Personal: personalData,
step2Business: businessData
}}
onBack={() => setCurrentStep(2)}
onSubmit={handleFinalSubmit}
/>
)}
</div>
);
}
// 🎯 KEY FEATURES:
// 1. Steps thay đổi based on user choice
// 2. Different schemas for different paths
// 3. Progress indicator adapts
// 4. Can still go back to change account type🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Bài 1: Simple Linear Wizard (15 phút)
/**
* 🎯 Mục tiêu: Tạo 3-step wizard cơ bản
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: Context, useReducer, dynamic steps
*
* Requirements:
* 1. Step 1: Name & Email
* 2. Step 2: Address & Phone
* 3. Step 3: Review & Submit
* 4. Progress bar hiển thị current step
* 5. Validate mỗi step trước khi next
* 6. Data persist khi back/forward
*
* 💡 Gợi ý:
* - Dùng useState cho currentStep và data
* - Mỗi step là separate component với own schema
* - Pass defaultValues để pre-fill khi back
*/
// TODO: Implement SimpleWizard component💡 Solution
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* Simple 3-step linear wizard
* @returns {JSX.Element} Wizard component
*/
// Schemas
const step1Schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address")
});
const step2Schema = z.object({
address: z.string().min(10, "Address must be at least 10 characters"),
phone: z.string().regex(/^\d{10}$/, "Phone must be 10 digits")
});
type Step1Data = z.infer<typeof step1Schema>;
type Step2Data = z.infer<typeof step2Schema>;
function Step1({ onNext, defaultValues }: {
onNext: (data: Step1Data) => void;
defaultValues?: Partial<Step1Data>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<Step1Data>({
resolver: zodResolver(step1Schema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Step 1: Basic Information</h2>
<div>
<label>Name</label>
<input {...register("name")} />
{errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
</div>
<div>
<label>Email</label>
<input {...register("email")} />
{errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
</div>
<button type="submit">Next →</button>
</form>
);
}
function Step2({ onNext, onBack, defaultValues }: {
onNext: (data: Step2Data) => void;
onBack: () => void;
defaultValues?: Partial<Step2Data>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<Step2Data>({
resolver: zodResolver(step2Schema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Step 2: Contact Details</h2>
<div>
<label>Address</label>
<input {...register("address")} />
{errors.address && <p style={{ color: 'red' }}>{errors.address.message}</p>}
</div>
<div>
<label>Phone</label>
<input {...register("phone")} placeholder="1234567890" />
{errors.phone && <p style={{ color: 'red' }}>{errors.phone.message}</p>}
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
function Step3({ data, onBack, onSubmit }: {
data: { step1: Step1Data; step2: Step2Data };
onBack: () => void;
onSubmit: () => void;
}) {
return (
<div>
<h2>Step 3: Review & Submit</h2>
<div>
<h3>Basic Information:</h3>
<p>Name: {data.step1.name}</p>
<p>Email: {data.step1.email}</p>
</div>
<div>
<h3>Contact Details:</h3>
<p>Address: {data.step2.address}</p>
<p>Phone: {data.step2.phone}</p>
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button onClick={onSubmit}>Submit</button>
</div>
</div>
);
}
function SimpleWizard() {
const [currentStep, setCurrentStep] = useState(1);
const [step1Data, setStep1Data] = useState<Step1Data | null>(null);
const [step2Data, setStep2Data] = useState<Step2Data | null>(null);
const handleStep1 = (data: Step1Data) => {
setStep1Data(data);
setCurrentStep(2);
};
const handleStep2 = (data: Step2Data) => {
setStep2Data(data);
setCurrentStep(3);
};
const handleSubmit = () => {
console.log('Submitted:', { ...step1Data, ...step2Data });
alert('Form submitted successfully!');
setCurrentStep(1);
setStep1Data(null);
setStep2Data(null);
};
return (
<div>
{/* Progress Bar */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '20px',
padding: '10px',
backgroundColor: '#f0f0f0'
}}>
<span style={{ fontWeight: currentStep >= 1 ? 'bold' : 'normal' }}>
1. Basic Info {currentStep > 1 && '✓'}
</span>
<span style={{ fontWeight: currentStep >= 2 ? 'bold' : 'normal' }}>
2. Contact {currentStep > 2 && '✓'}
</span>
<span style={{ fontWeight: currentStep >= 3 ? 'bold' : 'normal' }}>
3. Review
</span>
</div>
{/* Steps */}
{currentStep === 1 && (
<Step1 onNext={handleStep1} defaultValues={step1Data || undefined} />
)}
{currentStep === 2 && (
<Step2
onNext={handleStep2}
onBack={() => setCurrentStep(1)}
defaultValues={step2Data || undefined}
/>
)}
{currentStep === 3 && step1Data && step2Data && (
<Step3
data={{ step1: step1Data, step2: step2Data }}
onBack={() => setCurrentStep(2)}
onSubmit={handleSubmit}
/>
)}
</div>
);
}
// Example usage: Fill Step 1 → Next → Fill Step 2 → Next → Review → Submit⭐⭐ Bài 2: Wizard với Validation Summary (25 phút)
/**
* 🎯 Mục tiêu: Build wizard có validation status cho mỗi step
* ⏱️ Thời gian: 25 phút
*
* Scenario: User muốn biết step nào đã complete, step nào còn errors
*
* 🤔 PHÂN TÍCH:
* Approach A: Track validation status per step trong state
* Pros: Simple, explicit
* Cons: Need to manually update validation status
*
* Approach B: Validate all data silently, show status
* Pros: Always accurate
* Cons: Performance - validate unused data
*
* 💭 Chọn Approach A - explicit tracking
*
* Requirements:
* 1. Progress indicator shows: Incomplete | Completed ✓ | Current
* 2. Can click on completed steps to edit
* 3. Cannot skip to next step if current invalid
* 4. Show summary of all data before final submit
*/
// TODO: Implement WizardWithValidationStatus💡 Solution
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* Wizard với validation status tracking
* @returns {JSX.Element} Wizard with validation indicators
*/
const profileSchema = z.object({
username: z.string().min(3, "Username must be 3+ chars"),
bio: z.string().min(10, "Bio must be 10+ chars")
});
const preferencesSchema = z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean()
});
type ProfileData = z.infer<typeof profileSchema>;
type PreferencesData = z.infer<typeof preferencesSchema>;
type StepStatus = 'incomplete' | 'completed' | 'current';
function ProfileStep({ onNext, defaultValues }: {
onNext: (data: ProfileData) => void;
defaultValues?: Partial<ProfileData>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<ProfileData>({
resolver: zodResolver(profileSchema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Profile</h2>
<div>
<label>Username</label>
<input {...register("username")} />
{errors.username && <p style={{ color: 'red' }}>{errors.username.message}</p>}
</div>
<div>
<label>Bio</label>
<textarea {...register("bio")} rows={3} />
{errors.bio && <p style={{ color: 'red' }}>{errors.bio.message}</p>}
</div>
<button type="submit">Next →</button>
</form>
);
}
function PreferencesStep({ onNext, onBack, defaultValues }: {
onNext: (data: PreferencesData) => void;
onBack: () => void;
defaultValues?: Partial<PreferencesData>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<PreferencesData>({
resolver: zodResolver(preferencesSchema),
defaultValues: defaultValues || { theme: 'light', notifications: false }
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Preferences</h2>
<div>
<label>Theme</label>
<select {...register("theme")}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
{errors.theme && <p style={{ color: 'red' }}>{errors.theme.message}</p>}
</div>
<div>
<label>
<input type="checkbox" {...register("notifications")} />
Enable notifications
</label>
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
function ReviewStep({ data, onBack, onSubmit }: {
data: { profile: ProfileData; preferences: PreferencesData };
onBack: () => void;
onSubmit: () => void;
}) {
return (
<div>
<h2>Review</h2>
<div style={{ border: '1px solid #ccc', padding: '10px', marginBottom: '10px' }}>
<h3>Profile</h3>
<p>Username: {data.profile.username}</p>
<p>Bio: {data.profile.bio}</p>
</div>
<div style={{ border: '1px solid #ccc', padding: '10px', marginBottom: '10px' }}>
<h3>Preferences</h3>
<p>Theme: {data.preferences.theme}</p>
<p>Notifications: {data.preferences.notifications ? 'Enabled' : 'Disabled'}</p>
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button onClick={onSubmit}>Submit</button>
</div>
</div>
);
}
function WizardWithValidationStatus() {
const [currentStep, setCurrentStep] = useState(1);
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [preferencesData, setPreferencesData] = useState<PreferencesData | null>(null);
// Track which steps are completed
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const getStepStatus = (stepNumber: number): StepStatus => {
if (stepNumber === currentStep) return 'current';
if (completedSteps.has(stepNumber)) return 'completed';
return 'incomplete';
};
const handleProfileSubmit = (data: ProfileData) => {
setProfileData(data);
setCompletedSteps(prev => new Set(prev).add(1));
setCurrentStep(2);
};
const handlePreferencesSubmit = (data: PreferencesData) => {
setPreferencesData(data);
setCompletedSteps(prev => new Set(prev).add(2));
setCurrentStep(3);
};
const handleFinalSubmit = () => {
console.log('Submitted:', { profile: profileData, preferences: preferencesData });
alert('Settings saved!');
};
const goToStep = (step: number) => {
// Can only go to completed steps or current step
if (step <= currentStep || completedSteps.has(step - 1)) {
setCurrentStep(step);
}
};
return (
<div>
{/* Progress Indicator with Status */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '20px',
padding: '10px',
backgroundColor: '#f0f0f0'
}}>
{[
{ number: 1, label: 'Profile' },
{ number: 2, label: 'Preferences' },
{ number: 3, label: 'Review' }
].map(step => {
const status = getStepStatus(step.number);
return (
<button
key={step.number}
onClick={() => goToStep(step.number)}
disabled={status === 'incomplete' && step.number > currentStep}
style={{
fontWeight: status === 'current' ? 'bold' : 'normal',
color: status === 'completed' ? 'green' :
status === 'current' ? 'blue' : 'gray',
cursor: status === 'incomplete' ? 'not-allowed' : 'pointer',
border: 'none',
background: 'transparent',
fontSize: '16px'
}}
>
{step.number}. {step.label}
{status === 'completed' && ' ✓'}
{status === 'current' && ' ←'}
</button>
);
})}
</div>
{/* Steps */}
{currentStep === 1 && (
<ProfileStep
onNext={handleProfileSubmit}
defaultValues={profileData || undefined}
/>
)}
{currentStep === 2 && (
<PreferencesStep
onNext={handlePreferencesSubmit}
onBack={() => setCurrentStep(1)}
defaultValues={preferencesData || undefined}
/>
)}
{currentStep === 3 && profileData && preferencesData && (
<ReviewStep
data={{ profile: profileData, preferences: preferencesData }}
onBack={() => setCurrentStep(2)}
onSubmit={handleFinalSubmit}
/>
)}
</div>
);
}
// Example: Complete Step 1 → Step 1 shows ✓, can click to edit
// Try to click Step 3 before completing Step 2 → Disabled⭐⭐⭐ Bài 3: Wizard với Save Progress (40 phút)
/**
* 🎯 Mục tiêu: Build wizard có "Save & Continue Later" functionality
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là user, tôi muốn save progress và continue later,
* để không mất data nếu phải đóng browser"
*
* ✅ Acceptance Criteria:
* - [ ] "Save Progress" button ở mỗi step
* - [ ] Data saved to localStorage
* - [ ] Auto-load saved data on mount
* - [ ] "Clear Progress" button to reset
* - [ ] Show last saved timestamp
* - [ ] 3 steps: Personal Info → Work Info → Preferences
*
* 🎨 Technical Constraints:
* - Dùng localStorage (chưa học custom hooks, nên inline)
* - Serialize/deserialize data properly
* - Handle localStorage errors (quota exceeded)
*
* 🚨 Edge Cases cần handle:
* - localStorage not available (incognito mode)
* - Corrupted data in localStorage
* - Schema changes (old saved data)
*
* 📝 Implementation Checklist:
* - [ ] Save function
* - [ ] Load function on mount
* - [ ] Clear function
* - [ ] Error handling
* - [ ] UI feedback (saved timestamp)
*/
// TODO: Implement WizardWithSaveProgress💡 Solution
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* Wizard with save progress functionality
* Persists data to localStorage
* @returns {JSX.Element} Wizard with save/load capabilities
*/
const STORAGE_KEY = 'wizard_progress';
// Schemas
const personalInfoSchema = z.object({
firstName: z.string().min(2, "First name required"),
lastName: z.string().min(2, "Last name required"),
email: z.string().email("Invalid email")
});
const workInfoSchema = z.object({
company: z.string().min(2, "Company name required"),
position: z.string().min(2, "Position required"),
yearsOfExperience: z.string()
.transform(val => parseInt(val, 10))
.pipe(z.number().min(0, "Must be 0 or more"))
});
const preferencesSchema = z.object({
contactMethod: z.enum(['email', 'phone']),
subscribe: z.boolean()
});
type PersonalInfoData = z.infer<typeof personalInfoSchema>;
type WorkInfoData = z.infer<typeof workInfoSchema>;
type PreferencesData = z.infer<typeof preferencesSchema>;
type WizardData = {
currentStep: number;
step1?: PersonalInfoData;
step2?: WorkInfoData;
step3?: PreferencesData;
lastSaved?: string;
};
function PersonalInfoStep({ onNext, onSave, defaultValues }: {
onNext: (data: PersonalInfoData) => void;
onSave: (data: PersonalInfoData) => void;
defaultValues?: Partial<PersonalInfoData>;
}) {
const { register, handleSubmit, getValues, formState: { errors } } = useForm<PersonalInfoData>({
resolver: zodResolver(personalInfoSchema),
defaultValues
});
const handleSave = () => {
// Get current values without validation
const currentData = getValues();
onSave(currentData);
};
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Step 1: Personal Information</h2>
<div>
<label>First Name</label>
<input {...register("firstName")} />
{errors.firstName && <p style={{ color: 'red' }}>{errors.firstName.message}</p>}
</div>
<div>
<label>Last Name</label>
<input {...register("lastName")} />
{errors.lastName && <p style={{ color: 'red' }}>{errors.lastName.message}</p>}
</div>
<div>
<label>Email</label>
<input {...register("email")} />
{errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
</div>
<div>
<button type="button" onClick={handleSave}>💾 Save Progress</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
function WorkInfoStep({ onNext, onBack, onSave, defaultValues }: {
onNext: (data: WorkInfoData) => void;
onBack: () => void;
onSave: (data: WorkInfoData) => void;
defaultValues?: Partial<WorkInfoData>;
}) {
const { register, handleSubmit, getValues, formState: { errors } } = useForm<WorkInfoData>({
resolver: zodResolver(workInfoSchema),
defaultValues
});
const handleSave = () => {
const currentData = getValues();
onSave(currentData);
};
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Step 2: Work Information</h2>
<div>
<label>Company</label>
<input {...register("company")} />
{errors.company && <p style={{ color: 'red' }}>{errors.company.message}</p>}
</div>
<div>
<label>Position</label>
<input {...register("position")} />
{errors.position && <p style={{ color: 'red' }}>{errors.position.message}</p>}
</div>
<div>
<label>Years of Experience</label>
<input {...register("yearsOfExperience")} />
{errors.yearsOfExperience && <p style={{ color: 'red' }}>{errors.yearsOfExperience.message}</p>}
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="button" onClick={handleSave}>💾 Save Progress</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
function PreferencesStep({ onSubmit, onBack, onSave, defaultValues }: {
onSubmit: (data: PreferencesData) => void;
onBack: () => void;
onSave: (data: PreferencesData) => void;
defaultValues?: Partial<PreferencesData>;
}) {
const { register, handleSubmit, getValues, formState: { errors } } = useForm<PreferencesData>({
resolver: zodResolver(preferencesSchema),
defaultValues: defaultValues || { contactMethod: 'email', subscribe: false }
});
const handleSave = () => {
const currentData = getValues();
onSave(currentData);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Step 3: Preferences</h2>
<div>
<label>Preferred Contact Method</label>
<select {...register("contactMethod")}>
<option value="email">Email</option>
<option value="phone">Phone</option>
</select>
{errors.contactMethod && <p style={{ color: 'red' }}>{errors.contactMethod.message}</p>}
</div>
<div>
<label>
<input type="checkbox" {...register("subscribe")} />
Subscribe to newsletter
</label>
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="button" onClick={handleSave}>💾 Save Progress</button>
<button type="submit">Submit</button>
</div>
</form>
);
}
function WizardWithSaveProgress() {
const [currentStep, setCurrentStep] = useState(1);
const [step1Data, setStep1Data] = useState<PersonalInfoData | null>(null);
const [step2Data, setStep2Data] = useState<WorkInfoData | null>(null);
const [step3Data, setPreferencesData] = useState<PreferencesData | null>(null);
const [lastSaved, setLastSaved] = useState<string | null>(null);
// Load saved data on mount
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const data: WizardData = JSON.parse(saved);
setCurrentStep(data.currentStep || 1);
setStep1Data(data.step1 || null);
setStep2Data(data.step2 || null);
setPreferencesData(data.step3 || null);
setLastSaved(data.lastSaved || null);
}
} catch (error) {
console.error('Failed to load saved progress:', error);
// Corrupted data - clear it
localStorage.removeItem(STORAGE_KEY);
}
}, []);
// Save progress to localStorage
const saveProgress = (step: number, data: any) => {
try {
const wizardData: WizardData = {
currentStep: step,
step1: step1Data || undefined,
step2: step2Data || undefined,
step3: step3Data || undefined,
lastSaved: new Date().toLocaleString()
};
// Update with current step data
if (step === 1) wizardData.step1 = data;
if (step === 2) wizardData.step2 = data;
if (step === 3) wizardData.step3 = data;
localStorage.setItem(STORAGE_KEY, JSON.stringify(wizardData));
setLastSaved(wizardData.lastSaved);
alert('Progress saved!');
} catch (error) {
console.error('Failed to save progress:', error);
alert('Failed to save progress. localStorage may be full or disabled.');
}
};
// Clear all progress
const clearProgress = () => {
if (confirm('Are you sure you want to clear all progress?')) {
localStorage.removeItem(STORAGE_KEY);
setCurrentStep(1);
setStep1Data(null);
setStep2Data(null);
setPreferencesData(null);
setLastSaved(null);
}
};
const handleStep1Next = (data: PersonalInfoData) => {
setStep1Data(data);
saveProgress(2, data);
setCurrentStep(2);
};
const handleStep2Next = (data: WorkInfoData) => {
setStep2Data(data);
saveProgress(3, data);
setCurrentStep(3);
};
const handleFinalSubmit = (data: PreferencesData) => {
setPreferencesData(data);
const finalData = {
...step1Data,
...step2Data,
...data
};
console.log('Form submitted:', finalData);
alert('Application submitted successfully!');
// Clear saved progress after successful submit
localStorage.removeItem(STORAGE_KEY);
setCurrentStep(1);
setStep1Data(null);
setStep2Data(null);
setPreferencesData(null);
setLastSaved(null);
};
return (
<div>
{/* Header with last saved info */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '10px',
padding: '10px',
backgroundColor: '#e0e0e0'
}}>
<div>
{lastSaved && <small>Last saved: {lastSaved}</small>}
</div>
<button onClick={clearProgress} style={{ fontSize: '12px' }}>
🗑️ Clear Progress
</button>
</div>
{/* Progress indicator */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '20px',
padding: '10px',
backgroundColor: '#f0f0f0'
}}>
<span style={{ fontWeight: currentStep >= 1 ? 'bold' : 'normal' }}>
1. Personal
</span>
<span style={{ fontWeight: currentStep >= 2 ? 'bold' : 'normal' }}>
2. Work
</span>
<span style={{ fontWeight: currentStep >= 3 ? 'bold' : 'normal' }}>
3. Preferences
</span>
</div>
{/* Steps */}
{currentStep === 1 && (
<PersonalInfoStep
onNext={handleStep1Next}
onSave={(data) => saveProgress(1, data)}
defaultValues={step1Data || undefined}
/>
)}
{currentStep === 2 && (
<WorkInfoStep
onNext={handleStep2Next}
onBack={() => setCurrentStep(1)}
onSave={(data) => saveProgress(2, data)}
defaultValues={step2Data || undefined}
/>
)}
{currentStep === 3 && (
<PreferencesStep
onSubmit={handleFinalSubmit}
onBack={() => setCurrentStep(2)}
onSave={(data) => saveProgress(3, data)}
defaultValues={step3Data || undefined}
/>
)}
</div>
);
}
// Example usage:
// 1. Fill Step 1 → Click "Save Progress"
// 2. Close browser
// 3. Reopen → Data automatically loaded, continue from where you left off
// 4. Click "Clear Progress" to start over⭐⭐⭐⭐ Bài 4: Conditional Wizard với Skip Logic (60 phút)
/**
* 🎯 Mục tiêu: Build wizard với conditional steps (skip logic)
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Nhiệm vụ:
* 1. So sánh approaches:
* - A: Fixed step numbers, conditionally render
* - B: Dynamic step array based on conditions
* - C: State machine pattern
* 2. Document pros/cons
* 3. Chọn Approach B (dynamic step array)
* 4. Viết ADR
*
* ADR Template:
* - Context: Survey wizard where questions depend on previous answers
* - Decision: Dynamic step configuration based on form data
* - Rationale: Flexible, scalable, easy to test
* - Consequences: Slightly more complex state management
* - Alternatives: Fixed steps (not flexible), state machine (overkill)
*
* 💻 PHASE 2: Implementation (30 phút)
* Build product survey:
* - Step 1: Product type (software | hardware)
* - Step 2a (if software): Operating systems
* - Step 2b (if hardware): Warranty preference
* - Step 3: Feedback (always shown)
*
* 🧪 PHASE 3: Testing (10 phút)
* - [ ] Software path: Step 1 → 2a → 3
* - [ ] Hardware path: Step 1 → 2b → 3
* - [ ] Can change product type and path updates
* - [ ] Progress indicator shows correct steps
*/
// TODO: Implement ConditionalWizard with ADR💡 Solution
import { useState, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* ADR: Conditional Wizard Implementation
*
* Context:
* Need to build product survey where questions change based on product type.
* Software users get different questions than hardware users.
*
* Decision:
* Use dynamic step configuration array that changes based on form data.
* Calculate available steps on each render based on current data.
*
* Rationale:
* - Flexible: Easy to add new conditions
* - Maintainable: Step logic in one place
* - Testable: Can test step calculation independently
* - User-friendly: Progress bar adapts to actual path
*
* Consequences:
* - Need to recalculate steps on data change
* - Step numbers may change (use IDs instead)
* - More complex than fixed steps
*
* Alternatives Considered:
* - A: Fixed steps + conditional render → step numbers confusing
* - C: State machine (XState) → overkill for simple survey
*/
// Step schemas
const productTypeSchema = z.object({
productType: z.enum(['software', 'hardware'])
});
const softwareQuestionsSchema = z.object({
operatingSystems: z.array(z.enum(['windows', 'mac', 'linux']))
.min(1, "Select at least one OS")
});
const hardwareQuestionsSchema = z.object({
warrantyYears: z.enum(['1', '2', '3'])
});
const feedbackSchema = z.object({
rating: z.string()
.transform(val => parseInt(val, 10))
.pipe(z.number().min(1).max(5)),
comments: z.string().min(10, "Comments must be at least 10 characters")
});
type ProductTypeData = z.infer<typeof productTypeSchema>;
type SoftwareData = z.infer<typeof softwareQuestionsSchema>;
type HardwareData = z.infer<typeof hardwareQuestionsSchema>;
type FeedbackData = z.infer<typeof feedbackSchema>;
// Step type definition
type StepConfig = {
id: string;
label: string;
component: React.ComponentType<any>;
condition?: (data: any) => boolean;
};
function ProductTypeStep({ onNext, defaultValues }: {
onNext: (data: ProductTypeData) => void;
defaultValues?: Partial<ProductTypeData>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<ProductTypeData>({
resolver: zodResolver(productTypeSchema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>What type of product are you interested in?</h2>
<div>
<label>
<input type="radio" {...register("productType")} value="software" />
Software
</label>
</div>
<div>
<label>
<input type="radio" {...register("productType")} value="hardware" />
Hardware
</label>
</div>
{errors.productType && <p style={{ color: 'red' }}>{errors.productType.message}</p>}
<button type="submit">Next →</button>
</form>
);
}
function SoftwareQuestionsStep({ onNext, onBack, defaultValues }: {
onNext: (data: SoftwareData) => void;
onBack: () => void;
defaultValues?: Partial<SoftwareData>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<SoftwareData>({
resolver: zodResolver(softwareQuestionsSchema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Which operating systems do you use?</h2>
<div>
<label>
<input type="checkbox" {...register("operatingSystems")} value="windows" />
Windows
</label>
</div>
<div>
<label>
<input type="checkbox" {...register("operatingSystems")} value="mac" />
macOS
</label>
</div>
<div>
<label>
<input type="checkbox" {...register("operatingSystems")} value="linux" />
Linux
</label>
</div>
{errors.operatingSystems && <p style={{ color: 'red' }}>{errors.operatingSystems.message}</p>}
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
function HardwareQuestionsStep({ onNext, onBack, defaultValues }: {
onNext: (data: HardwareData) => void;
onBack: () => void;
defaultValues?: Partial<HardwareData>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<HardwareData>({
resolver: zodResolver(hardwareQuestionsSchema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onNext)}>
<h2>Preferred warranty duration?</h2>
<div>
<label>
<input type="radio" {...register("warrantyYears")} value="1" />
1 Year
</label>
</div>
<div>
<label>
<input type="radio" {...register("warrantyYears")} value="2" />
2 Years
</label>
</div>
<div>
<label>
<input type="radio" {...register("warrantyYears")} value="3" />
3 Years
</label>
</div>
{errors.warrantyYears && <p style={{ color: 'red' }}>{errors.warrantyYears.message}</p>}
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
}
function FeedbackStep({ onSubmit, onBack, defaultValues }: {
onSubmit: (data: FeedbackData) => void;
onBack: () => void;
defaultValues?: Partial<FeedbackData>;
}) {
const { register, handleSubmit, formState: { errors } } = useForm<FeedbackData>({
resolver: zodResolver(feedbackSchema),
defaultValues
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Your Feedback</h2>
<div>
<label>Rate your experience (1-5):</label>
<select {...register("rating")}>
<option value="">Select rating</option>
<option value="1">1 - Poor</option>
<option value="2">2 - Fair</option>
<option value="3">3 - Good</option>
<option value="4">4 - Very Good</option>
<option value="5">5 - Excellent</option>
</select>
{errors.rating && <p style={{ color: 'red' }}>{errors.rating.message}</p>}
</div>
<div>
<label>Additional comments:</label>
<textarea {...register("comments")} rows={4} />
{errors.comments && <p style={{ color: 'red' }}>{errors.comments.message}</p>}
</div>
<div>
<button type="button" onClick={onBack}>← Back</button>
<button type="submit">Submit Survey</button>
</div>
</form>
);
}
function ConditionalWizard() {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [formData, setFormData] = useState<any>({});
// Define all possible steps with conditions
const allSteps: StepConfig[] = [
{
id: 'product-type',
label: 'Product Type',
component: ProductTypeStep
},
{
id: 'software-questions',
label: 'Software Questions',
component: SoftwareQuestionsStep,
condition: (data) => data['product-type']?.productType === 'software'
},
{
id: 'hardware-questions',
label: 'Hardware Questions',
component: HardwareQuestionsStep,
condition: (data) => data['product-type']?.productType === 'hardware'
},
{
id: 'feedback',
label: 'Feedback',
component: FeedbackStep
}
];
// Calculate active steps based on current data
const activeSteps = useMemo(() => {
return allSteps.filter(step =>
!step.condition || step.condition(formData)
);
}, [formData]);
const currentStep = activeSteps[currentStepIndex];
const CurrentStepComponent = currentStep.component;
const handleStepSubmit = (data: any) => {
// Save data for this step
setFormData(prev => ({
...prev,
[currentStep.id]: data
}));
// Move to next step or finish
if (currentStepIndex < activeSteps.length - 1) {
setCurrentStepIndex(prev => prev + 1);
} else {
// Final submission
const finalData = {
...formData,
[currentStep.id]: data
};
console.log('Survey submitted:', finalData);
alert('Thank you for your feedback!');
}
};
const handleBack = () => {
if (currentStepIndex > 0) {
setCurrentStepIndex(prev => prev - 1);
}
};
return (
<div>
{/* Dynamic Progress Indicator */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '20px',
padding: '10px',
backgroundColor: '#f0f0f0'
}}>
{activeSteps.map((step, index) => (
<span
key={step.id}
style={{
fontWeight: index === currentStepIndex ? 'bold' : 'normal',
color: index < currentStepIndex ? 'green' :
index === currentStepIndex ? 'blue' : 'gray'
}}
>
{index + 1}. {step.label}
{index < currentStepIndex && ' ✓'}
</span>
))}
</div>
{/* Current Step */}
<CurrentStepComponent
onNext={handleStepSubmit}
onSubmit={handleStepSubmit}
onBack={handleBack}
defaultValues={formData[currentStep.id]}
/>
</div>
);
}
// Example usage:
// Select "Software" → See OS questions → Skip warranty questions
// Go back, select "Hardware" → Skip OS questions → See warranty questions
// Progress bar adapts to show only relevant steps⭐⭐⭐⭐⭐ Bài 5: Production Job Application Wizard (90 phút)
/**
* 🎯 Mục tiêu: Production-grade job application wizard
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
* 5-step wizard: Personal → Education → Experience → Skills → Review
*
* Step 1 - Personal: name, email, phone, location
* Step 2 - Education: degree, school, graduation year (array)
* Step 3 - Experience: company, position, years (array)
* Step 4 - Skills: technical skills (tags), languages
* Step 5 - Review: Summary + file upload (resume PDF)
*
* 🏗️ Technical Design Doc:
* 1. Component Architecture
* - WizardProvider (Context)
* - WizardContainer
* - StepComponents (5)
* - ProgressIndicator
* - ValidationSummary
*
* 2. State Management
* - useReducer for wizard state
* - Context for sharing
* - localStorage persistence
*
* 3. Validation Strategy
* - Zod schemas per step
* - Partial validation (can save incomplete)
* - Full validation on final submit
*
* 4. Performance
* - Memoize step components
* - Debounce auto-save (comment for future: useDebounce)
*
* 5. Error Handling
* - Field-level errors
* - Step-level validation summary
* - localStorage quota handling
* - File upload validation
*
* ✅ Production Checklist:
* - [ ] Full TypeScript types
* - [ ] All CRUD operations (add/edit/delete for arrays)
* - [ ] Validation for all fields
* - [ ] Auto-save to localStorage
* - [ ] Progress persistence
* - [ ] Clear & restart
* - [ ] File upload (PDF only, max 5MB)
* - [ ] Accessibility (labels, ARIA)
* - [ ] Loading states
* - [ ] Error boundaries (comment)
*
* 📝 Documentation:
* - Component hierarchy diagram (ASCII)
* - State shape documentation
* - Usage examples
*/
// TODO: Full production implementation💡 Solution
import { createContext, useContext, useReducer, useEffect, ReactNode, memo } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/**
* PRODUCTION JOB APPLICATION WIZARD
*
* Architecture:
* ```
* WizardProvider (Context + Reducer)
* │
* ├─ ProgressIndicator
* ├─ ValidationSummary
* └─ WizardSteps
* ├─ PersonalInfoStep
* ├─ EducationStep (field array)
* ├─ ExperienceStep (field array)
* ├─ SkillsStep
* └─ ReviewStep (file upload)
* ```
*
* Features:
* - Auto-save to localStorage
* - Step validation
* - Array fields (education, experience)
* - File upload
* - Progress tracking
*/
const STORAGE_KEY = 'job_application_wizard';
// ============================================
// SCHEMAS
// ============================================
const personalInfoSchema = z.object({
firstName: z.string().min(2, "First name required"),
lastName: z.string().min(2, "Last name required"),
email: z.string().email("Invalid email"),
phone: z.string().regex(/^\d{10}$/, "Phone must be 10 digits"),
location: z.string().min(2, "Location required")
});
const educationEntrySchema = z.object({
degree: z.string().min(2, "Degree required"),
school: z.string().min(2, "School required"),
graduationYear: z.string()
.transform(val => parseInt(val, 10))
.pipe(z.number().min(1950).max(2030))
});
const educationSchema = z.object({
education: z.array(educationEntrySchema).min(1, "Add at least one education entry")
});
const experienceEntrySchema = z.object({
company: z.string().min(2, "Company required"),
position: z.string().min(2, "Position required"),
years: z.string()
.transform(val => parseInt(val, 10))
.pipe(z.number().min(0).max(50))
});
const experienceSchema = z.object({
experience: z.array(experienceEntrySchema).min(1, "Add at least one experience entry")
});
const skillsSchema = z.object({
technicalSkills: z.string().min(1, "Technical skills required"),
languages: z.string().min(1, "Languages required")
});
type PersonalInfoData = z.infer<typeof personalInfoSchema>;
type EducationData = z.infer<typeof educationSchema>;
type ExperienceData = z.infer<typeof experienceSchema>;
type SkillsData = z.infer<typeof skillsSchema>;
// ============================================
// CONTEXT & REDUCER
// ============================================
type WizardState = {
currentStep: number;
totalSteps: number;
formData: {
personal?: PersonalInfoData;
education?: EducationData;
experience?: ExperienceData;
skills?: SkillsData;
resume?: File;
};
completedSteps: Set<number>;
lastSaved: string | null;
};
type WizardAction =
| { type: 'NEXT_STEP' }
| { type: 'PREVIOUS_STEP' }
| { type: 'GO_TO_STEP'; payload: number }
| { type: 'UPDATE_STEP'; payload: { step: number; data: any } }
| { type: 'MARK_COMPLETE'; payload: number }
| { type: 'LOAD_SAVED'; payload: WizardState }
| { type: 'RESET' };
const wizardReducer = (state: WizardState, action: WizardAction): WizardState => {
switch (action.type) {
case 'NEXT_STEP':
return {
...state,
currentStep: Math.min(state.currentStep + 1, state.totalSteps)
};
case 'PREVIOUS_STEP':
return {
...state,
currentStep: Math.max(state.currentStep - 1, 1)
};
case 'GO_TO_STEP':
return { ...state, currentStep: action.payload };
case 'UPDATE_STEP':
const stepKey = ['personal', 'education', 'experience', 'skills', 'review'][action.payload - 1];
return {
...state,
formData: { ...state.formData, [stepKey]: action.payload.data },
lastSaved: new Date().toLocaleTimeString()
};
case 'MARK_COMPLETE':
return {
...state,
completedSteps: new Set([...state.completedSteps, action.payload])
};
case 'LOAD_SAVED':
return { ...action.payload, completedSteps: new Set(action.payload.completedSteps) };
case 'RESET':
return {
currentStep: 1,
totalSteps: 5,
formData: {},
completedSteps: new Set(),
lastSaved: null
};
default:
return state;
}
};
const WizardContext = createContext<{
state: WizardState;
dispatch: React.Dispatch<WizardAction>;
} | null>(null);
function WizardProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(wizardReducer, {
currentStep: 1,
totalSteps: 5,
formData: {},
completedSteps: new Set(),
lastSaved: null
});
// Auto-save to localStorage
useEffect(() => {
try {
const serialized = {
...state,
completedSteps: Array.from(state.completedSteps)
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(serialized));
} catch (error) {
console.error('Auto-save failed:', error);
}
}, [state]);
// Load on mount
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
dispatch({ type: 'LOAD_SAVED', payload: parsed });
}
} catch (error) {
console.error('Load failed:', error);
}
}, []);
return (
<WizardContext.Provider value={{ state, dispatch }}>
{children}
</WizardContext.Provider>
);
}
function useWizard() {
const context = useContext(WizardContext);
if (!context) throw new Error('useWizard must be within WizardProvider');
return context;
}
// ============================================
// STEPS
// ============================================
const PersonalInfoStep = memo(function PersonalInfoStep() {
const { state, dispatch } = useWizard();
const { register, handleSubmit, formState: { errors } } = useForm<PersonalInfoData>({
resolver: zodResolver(personalInfoSchema),
defaultValues: state.formData.personal
});
const onSubmit = (data: PersonalInfoData) => {
dispatch({ type: 'UPDATE_STEP', payload: { step: 1, data } });
dispatch({ type: 'MARK_COMPLETE', payload: 1 });
dispatch({ type: 'NEXT_STEP' });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Personal Information</h2>
<div>
<input {...register("firstName")} placeholder="First Name" />
{errors.firstName && <p style={{ color: 'red' }}>{errors.firstName.message}</p>}
</div>
<div>
<input {...register("lastName")} placeholder="Last Name" />
{errors.lastName && <p style={{ color: 'red' }}>{errors.lastName.message}</p>}
</div>
<div>
<input {...register("email")} placeholder="Email" />
{errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
</div>
<div>
<input {...register("phone")} placeholder="Phone (10 digits)" />
{errors.phone && <p style={{ color: 'red' }}>{errors.phone.message}</p>}
</div>
<div>
<input {...register("location")} placeholder="Location" />
{errors.location && <p style={{ color: 'red' }}>{errors.location.message}</p>}
</div>
<button type="submit">Next →</button>
</form>
);
});
const EducationStep = memo(function EducationStep() {
const { state, dispatch } = useWizard();
const { register, control, handleSubmit, formState: { errors } } = useForm<EducationData>({
resolver: zodResolver(educationSchema),
defaultValues: state.formData.education || { education: [{ degree: '', school: '', graduationYear: '' }] }
});
const { fields, append, remove } = useFieldArray({ control, name: 'education' });
const onSubmit = (data: EducationData) => {
dispatch({ type: 'UPDATE_STEP', payload: { step: 2, data } });
dispatch({ type: 'MARK_COMPLETE', payload: 2 });
dispatch({ type: 'NEXT_STEP' });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Education</h2>
{fields.map((field, index) => (
<div key={field.id} style={{ border: '1px solid #ccc', padding: '10px', marginBottom: '10px' }}>
<h4>Entry {index + 1}</h4>
<div>
<input {...register(`education.${index}.degree`)} placeholder="Degree" />
{errors.education?.[index]?.degree && <p style={{ color: 'red' }}>{errors.education[index].degree.message}</p>}
</div>
<div>
<input {...register(`education.${index}.school`)} placeholder="School" />
{errors.education?.[index]?.school && <p style={{ color: 'red' }}>{errors.education[index].school.message}</p>}
</div>
<div>
<input {...register(`education.${index}.graduationYear`)} placeholder="Graduation Year" />
{errors.education?.[index]?.graduationYear && <p style={{ color: 'red' }}>{errors.education[index].graduationYear.message}</p>}
</div>
{fields.length > 1 && <button type="button" onClick={() => remove(index)}>Remove</button>}
</div>
))}
<button type="button" onClick={() => append({ degree: '', school: '', graduationYear: '' })}>Add Education</button>
<div>
<button type="button" onClick={() => dispatch({ type: 'PREVIOUS_STEP' })}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
});
const ExperienceStep = memo(function ExperienceStep() {
const { state, dispatch } = useWizard();
const { register, control, handleSubmit, formState: { errors } } = useForm<ExperienceData>({
resolver: zodResolver(experienceSchema),
defaultValues: state.formData.experience || { experience: [{ company: '', position: '', years: '' }] }
});
const { fields, append, remove } = useFieldArray({ control, name: 'experience' });
const onSubmit = (data: ExperienceData) => {
dispatch({ type: 'UPDATE_STEP', payload: { step: 3, data } });
dispatch({ type: 'MARK_COMPLETE', payload: 3 });
dispatch({ type: 'NEXT_STEP' });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Work Experience</h2>
{fields.map((field, index) => (
<div key={field.id} style={{ border: '1px solid #ccc', padding: '10px', marginBottom: '10px' }}>
<h4>Entry {index + 1}</h4>
<div>
<input {...register(`experience.${index}.company`)} placeholder="Company" />
{errors.experience?.[index]?.company && <p style={{ color: 'red' }}>{errors.experience[index].company.message}</p>}
</div>
<div>
<input {...register(`experience.${index}.position`)} placeholder="Position" />
{errors.experience?.[index]?.position && <p style={{ color: 'red' }}>{errors.experience[index].position.message}</p>}
</div>
<div>
<input {...register(`experience.${index}.years`)} placeholder="Years" />
{errors.experience?.[index]?.years && <p style={{ color: 'red' }}>{errors.experience[index].years.message}</p>}
</div>
{fields.length > 1 && <button type="button" onClick={() => remove(index)}>Remove</button>}
</div>
))}
<button type="button" onClick={() => append({ company: '', position: '', years: '' })}>Add Experience</button>
<div>
<button type="button" onClick={() => dispatch({ type: 'PREVIOUS_STEP' })}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
});
const SkillsStep = memo(function SkillsStep() {
const { state, dispatch } = useWizard();
const { register, handleSubmit, formState: { errors } } = useForm<SkillsData>({
resolver: zodResolver(skillsSchema),
defaultValues: state.formData.skills
});
const onSubmit = (data: SkillsData) => {
dispatch({ type: 'UPDATE_STEP', payload: { step: 4, data } });
dispatch({ type: 'MARK_COMPLETE', payload: 4 });
dispatch({ type: 'NEXT_STEP' });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Skills</h2>
<div>
<label>Technical Skills (comma-separated)</label>
<input {...register("technicalSkills")} placeholder="React, Node.js, Python" />
{errors.technicalSkills && <p style={{ color: 'red' }}>{errors.technicalSkills.message}</p>}
</div>
<div>
<label>Languages (comma-separated)</label>
<input {...register("languages")} placeholder="English, Vietnamese" />
{errors.languages && <p style={{ color: 'red' }}>{errors.languages.message}</p>}
</div>
<div>
<button type="button" onClick={() => dispatch({ type: 'PREVIOUS_STEP' })}>← Back</button>
<button type="submit">Next →</button>
</div>
</form>
);
});
function ReviewStep() {
const { state, dispatch } = useWizard();
const handleSubmit = () => {
console.log('Application submitted:', state.formData);
alert('Application submitted successfully!');
localStorage.removeItem(STORAGE_KEY);
dispatch({ type: 'RESET' });
};
return (
<div>
<h2>Review Your Application</h2>
{state.formData.personal && (
<div style={{ marginBottom: '15px' }}>
<h3>Personal Info</h3>
<p>Name: {state.formData.personal.firstName} {state.formData.personal.lastName}</p>
<p>Email: {state.formData.personal.email}</p>
<p>Phone: {state.formData.personal.phone}</p>
<p>Location: {state.formData.personal.location}</p>
</div>
)}
{state.formData.education && (
<div style={{ marginBottom: '15px' }}>
<h3>Education</h3>
{state.formData.education.education.map((edu, i) => (
<p key={i}>{edu.degree} from {edu.school} ({edu.graduationYear})</p>
))}
</div>
)}
{state.formData.experience && (
<div style={{ marginBottom: '15px' }}>
<h3>Experience</h3>
{state.formData.experience.experience.map((exp, i) => (
<p key={i}>{exp.position} at {exp.company} ({exp.years} years)</p>
))}
</div>
)}
{state.formData.skills && (
<div style={{ marginBottom: '15px' }}>
<h3>Skills</h3>
<p>Technical: {state.formData.skills.technicalSkills}</p>
<p>Languages: {state.formData.skills.languages}</p>
</div>
)}
<div>
<button onClick={() => dispatch({ type: 'PREVIOUS_STEP' })}>← Back</button>
<button onClick={handleSubmit}>Submit Application</button>
</div>
</div>
);
}
function ProgressIndicator() {
const { state, dispatch } = useWizard();
const steps = ['Personal', 'Education', 'Experience', 'Skills', 'Review'];
return (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px', padding: '10px', backgroundColor: '#f0f0f0' }}>
{steps.map((label, index) => {
const stepNum = index + 1;
const isComplete = state.completedSteps.has(stepNum);
const isCurrent = state.currentStep === stepNum;
return (
<button
key={stepNum}
onClick={() => isComplete && dispatch({ type: 'GO_TO_STEP', payload: stepNum })}
disabled={!isComplete && stepNum > state.currentStep}
style={{
fontWeight: isCurrent ? 'bold' : 'normal',
color: isComplete ? 'green' : isCurrent ? 'blue' : 'gray',
cursor: isComplete ? 'pointer' : 'default',
border: 'none',
background: 'transparent'
}}
>
{stepNum}. {label} {isComplete && '✓'}
</button>
);
})}
</div>
);
}
function JobApplicationWizard() {
return (
<WizardProvider>
<div>
<h1>Job Application</h1>
<ProgressIndicator />
<WizardSteps />
</div>
</WizardProvider>
);
}
function WizardSteps() {
const { state } = useWizard();
return (
<>
{state.currentStep === 1 && <PersonalInfoStep />}
{state.currentStep === 2 && <EducationStep />}
{state.currentStep === 3 && <ExperienceStep />}
{state.currentStep === 4 && <SkillsStep />}
{state.currentStep === 5 && <ReviewStep />}
</>
);
}
// Usage: Full-featured wizard with auto-save, array fields, validation, progress tracking📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: State Management Approaches
| Approach | useState | Context + useReducer | External Library |
|---|---|---|---|
| Complexity | ✅ Simple | ⚠️ Medium | ❌ High |
| Boilerplate | ✅ Minimal | ⚠️ Moderate | ❌ Lots |
| Scalability | ❌ Limited (prop drilling) | ✅ Good | ✅ Excellent |
| Type Safety | ⚠️ Manual | ✅ Good with TS | ✅ Excellent |
| DevTools | ❌ None | ❌ Basic | ✅ Redux DevTools |
| Learning Curve | ✅ Easy | ⚠️ Medium | ❌ Steep |
| Bundle Size | ✅ 0KB | ✅ 0KB | ❌ ~3-10KB |
| Best For | 2-3 steps | 3-7 steps | 8+ steps, complex logic |
Decision Tree: Khi nào dùng approach nào?
┌──────────────────────────────────┐
│ Câu hỏi 1: Bao nhiêu steps? │
└──────────────────────────────────┘
│
├─ 2-3 steps ──► useState (simple)
│
├─ 4-7 steps ──► Context + useReducer
│
└─ 8+ steps ──► Continue
│
┌──────────────────────────────────┐
│ Câu hỏi 2: Conditional steps? │
└──────────────────────────────────┘
│
├─ NO ──► Context + useReducer
│
└─ YES ──► Continue
│
┌──────────────────────────────────┐
│ Câu hỏi 3: Complex logic (undo, │
│ time-travel, debugging)? │
└──────────────────────────────────┘
│
├─ NO ──► Context + useReducer
│
└─ YES ──► Consider Redux/ZustandWhen to Use Each Pattern
✅ useState khi:
- Simple linear wizard (2-3 steps)
- No conditional logic
- Prototype/MVP phase
- Learning React forms
✅ Context + useReducer khi:
- Medium complexity (4-7 steps)
- Need to share state across components
- Conditional steps (some complexity)
- Production app, không cần DevTools
✅ External Library (Redux/Zustand) khi:
- Large wizard (8+ steps)
- Complex state transitions
- Need undo/redo
- Time-travel debugging essential
- Team already using Redux ecosystem
🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Steps Không Pre-fill khi Back ❌
// ❌ BUG: Khi back từ Step 2 về Step 1, data bị mất
function BuggyWizard() {
const [step, setStep] = useState(1);
const [step1Data, setStep1Data] = useState(null);
return (
<>
{step === 1 && (
<Step1
onNext={(data) => {
setStep1Data(data);
setStep(2);
}}
// ❌ Forgot defaultValues!
/>
)}
</>
);
}
// Step component
function Step1({ onNext }) {
const { register, handleSubmit } = useForm();
return <form onSubmit={handleSubmit(onNext)}>...</form>;
}🔍 Debug Questions:
- Tại sao input fields empty khi back?
- Data có bị mất không hay chỉ không hiển thị?
- Fix thế nào?
💡 Solution:
Xem giải thích
Vấn đề:
- Data được save trong
step1Datastate - Nhưng không pass vào
defaultValuescủa useForm - Khi Step1 re-mount, form start với empty values
Fix:
{
step === 1 && (
<Step1
onNext={(data) => {
setStep1Data(data);
setStep(2);
}}
defaultValues={step1Data} // ✅ Pass saved data
/>
);
}
// Step component
function Step1({ onNext, defaultValues }) {
const { register, handleSubmit } = useForm({
defaultValues, // ✅ Use it
});
return <form onSubmit={handleSubmit(onNext)}>...</form>;
}Prevention:
- Always pass
defaultValuesprop to step components - Test back navigation: Step 1 → fill → Step 2 → back → verify data still there
- Consider auto-save to prevent data loss
Bug 2: Progress Indicator Không Update ❌
// ❌ BUG: Completed checkmarks không hiện
function WizardWithProgress() {
const [currentStep, setCurrentStep] = useState(1);
const completedSteps = new Set(); // ❌ Bug here!
const handleStepComplete = (step) => {
completedSteps.add(step); // ❌ Mutating, no re-render!
setCurrentStep(step + 1);
};
return (
<div>
{[1, 2, 3].map((step) => (
<span key={step}>
{step}. Step {completedSteps.has(step) && '✓'}
</span>
))}
</div>
);
}🔍 Debug Questions:
- Tại sao checkmarks không xuất hiện sau complete step?
completedSteps.add()có chạy không?- Vấn đề với
new Set()placement?
💡 Solution:
Xem giải thích
Vấn đề:
completedStepsđược recreate mỗi render (không phải state).add()mutate Set nhưng không trigger re-render- Ngay cả khi add, lần render sau Set lại empty
Fix Option 1: useState với immutable update
const [completedSteps, setCompletedSteps] = useState(new Set());
const handleStepComplete = (step) => {
setCompletedSteps((prev) => new Set([...prev, step])); // ✅ Immutable
setCurrentStep(step + 1);
};Fix Option 2: useState với array
const [completedSteps, setCompletedSteps] = useState([]);
const handleStepComplete = (step) => {
setCompletedSteps((prev) => [...prev, step]);
setCurrentStep(step + 1);
};
// In render:
{
completedSteps.includes(step) && '✓';
}Prevention:
- Always use useState for data that affects rendering
- Set/Map need immutable updates:
new Set([...prev, item]) - Test UI updates after state changes
Bug 3: localStorage Quota Exceeded ❌
// ❌ BUG: Crash khi save large form data
function WizardWithSave() {
const [formData, setFormData] = useState({});
const saveProgress = () => {
// ❌ No error handling!
localStorage.setItem('wizard', JSON.stringify(formData));
};
return (
<form>
<input
onChange={(e) =>
setFormData({
...formData,
largeField: e.target.value.repeat(100000), // Large data
})
}
/>
<button onClick={saveProgress}>Save</button>
</form>
);
}🔍 Debug Questions:
- Khi nào localStorage.setItem throw error?
- Quota limit là bao nhiêu?
- Làm sao handle gracefully?
💡 Solution:
Xem giải thích
Vấn đề:
- localStorage quota: ~5-10MB (varies by browser)
setItemthrowsQuotaExceededErrorwhen full- No try-catch → app crashes
Fix:
const saveProgress = () => {
try {
const serialized = JSON.stringify(formData);
// Check size before saving
const sizeInMB = new Blob([serialized]).size / 1024 / 1024;
if (sizeInMB > 5) {
alert('Form data too large to save locally. Please submit soon.');
return;
}
localStorage.setItem('wizard', serialized);
alert('Progress saved!');
} catch (error) {
if (error.name === 'QuotaExceededError') {
alert('Storage quota exceeded. Please clear old data or submit form.');
} else {
console.error('Save failed:', error);
alert('Failed to save progress.');
}
}
};Additional fixes:
// Compress data before saving (simple approach)
const saveProgress = () => {
try {
// Only save essential fields, not entire state
const essentialData = {
step1: formData.step1,
step2: formData.step2,
// Skip large files, temp data
};
localStorage.setItem('wizard', JSON.stringify(essentialData));
} catch (error) {
// Handle error
}
};Prevention:
- Always wrap localStorage in try-catch
- Check data size before saving
- Don't save unnecessary data (files, temp state)
- Provide fallback: server-side save if localStorage fails
- Test with large data scenarios
✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
- [ ] Tôi hiểu wizard pattern là gì và khi nào nên dùng
- [ ] Tôi biết cách chia form thành multiple steps
- [ ] Tôi biết cách validate từng step với Zod
- [ ] Tôi biết cách persist data khi navigate giữa steps
- [ ] Tôi biết cách implement back/forward navigation
- [ ] Tôi biết cách track completed steps
- [ ] Tôi biết cách build progress indicator
- [ ] Tôi biết cách save/load progress từ localStorage
- [ ] Tôi biết cách handle conditional steps (skip logic)
- [ ] Tôi biết cách manage wizard state với useState vs Context
- [ ] Tôi biết cách prevent data loss (defaultValues)
- [ ] Tôi biết cách handle localStorage errors
Code Review Checklist
Architecture:
- [ ] Steps clearly separated (single responsibility)
- [ ] State management appropriate for complexity
- [ ] No prop drilling (use Context if needed)
- [ ] Component hierarchy logical
Navigation:
- [ ] Can move forward (validated)
- [ ] Can move backward (data preserved)
- [ ] Cannot skip steps without completing previous
- [ ] Progress indicator accurate
Validation:
- [ ] Each step has schema
- [ ] Validation runs before next
- [ ] Errors displayed clearly
- [ ] Can save incomplete data (if feature exists)
Data Persistence:
- [ ] defaultValues passed to all steps
- [ ] localStorage wrapped in try-catch
- [ ] Data serialization/deserialization correct
- [ ] Clear progress functionality works
UX:
- [ ] Progress indicator shows current step
- [ ] Completed steps marked visually
- [ ] Loading states for async operations
- [ ] Success/error feedback
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Task: Add "Save Draft" Feature
Lấy bất kỳ wizard từ bài tập hôm nay và thêm:
- "Save Draft" button ở mỗi step (không require validation)
- Auto-save every 30 seconds (dùng
setInterval) - Show "Draft saved at [timestamp]" feedback
- Load draft on mount with confirmation: "Continue from draft or start fresh?"
Nâng cao (60 phút)
Task: Build Survey Wizard với Dynamic Questions
Requirements:
- Step 1: Demographics (age group: 18-25, 26-35, 36-45, 46+)
- Step 2: Conditional questions based on age:
- 18-25: College experience questions
- 26-35: Career satisfaction questions
- 36-45: Work-life balance questions
- 46+: Retirement planning questions
- Step 3: General feedback (all ages)
Features:
- Progress bar adapts to age group (different total steps)
- Validation per step
- Save progress
- Export results as JSON
- Show summary with visualizations (simple text charts OK)
Advanced:
- Add "Skip this question" option
- Track time spent per step
- Prevent duplicate submissions (check localStorage for completion flag)
📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Hook Form - Multi-step Forms
UX Patterns for Multi-step Forms
localStorage API
Đọc thêm
Wizard Pattern in Design Systems
- Ant Design Steps component
- Material-UI Stepper
Advanced State Management for Forms
- When to use Redux for forms
- Form state machines (XState)
Accessibility in Multi-step Forms
- ARIA live regions for step changes
- Keyboard navigation patterns
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền
- Ngày 11-12: useState patterns - foundation for simple wizards
- Ngày 26-30: useReducer - for complex wizard state
- Ngày 36-38: Context API - for sharing wizard state
- Ngày 41-42: React Hook Form - form handling per step
- Ngày 43: Zod schemas - validation per step
Hướng tới
- Ngày 45: Project 6 - sẽ integrate wizard vào registration flow
- Phase 6 (Testing): Test wizard flows, step navigation
- Phase 6 (A11y): Accessibility cho wizards (focus management, ARIA)
- Future: Router integration (URL per step - chưa học router)
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. URL Synchronization
// Future upgrade: Sync step với URL
// Ngày 45+ sẽ học React Router, có thể:
// /checkout/shipping → Step 1
// /checkout/payment → Step 2
// /checkout/review → Step 3
// Benefits:
// - Shareable links
// - Browser back/forward works
// - Refresh doesn't lose step2. Analytics Tracking
// Track wizard completion funnel
useEffect(() => {
// Log to analytics when step changes
analytics.track('Wizard Step Viewed', {
wizard: 'checkout',
step: currentStep,
timestamp: new Date(),
});
}, [currentStep]);
// Track abandonment
window.addEventListener('beforeunload', () => {
if (currentStep < totalSteps) {
analytics.track('Wizard Abandoned', {
step: currentStep,
completedSteps: Array.from(completedSteps),
});
}
});3. Server-side Save
// For important data, don't rely only on localStorage
const saveProgress = async () => {
try {
// Try localStorage first (fast)
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
// Then sync to server (reliable)
await fetch('/api/wizard/save', {
method: 'POST',
body: JSON.stringify(data),
});
} catch (error) {
// Handle errors
}
};Câu Hỏi Phỏng Vấn
Junior Level:
Q: Wizard pattern là gì? Khi nào dùng? A: Chia form phức tạp thành nhiều bước. Dùng khi >5-6 fields, hoặc logical groupings
Q: Làm sao prevent data loss khi navigate giữa steps? A: Pass
defaultValuescho useForm, hoặc save vào state/localStorage
Mid Level: 3. Q: Làm sao implement conditional steps (skip logic)? A: Dynamic step array based on form data, filter steps theo conditions
- Q: useState vs Context cho wizard state? A: useState: 2-3 steps, simple. Context: 4+ steps, need sharing across components
Senior Level: 5. Q: Architect wizard với 15+ steps, complex branching, server sync? A:
- State machine pattern (XState) cho complex logic
- URL sync mỗi step
- Server-side save + conflict resolution
- Optimistic UI updates
- Analytics funnel tracking
- Q: Performance optimization cho large wizard? A:
- Code splitting per step (React.lazy)
- Memoize step components
- Debounce auto-save
- Virtual scrolling cho long lists in steps
- Lazy validation (only validate visible step)
War Stories
Story 1: The Infinite Loop "Wizard auto-saved on every state change. State change triggered save. Save updated lastSaved timestamp. lastSaved in dependency array → infinite loop. Fix: separate auto-save logic from render logic, debounce saves."
Story 2: Back Button Nightmare "Users clicked browser back → left wizard flow → lost all data. Fix: (1) Save to localStorage, (2) Add beforeunload warning, (3) Eventually: sync step with URL so back/forward works naturally."
Story 3: Mobile Gotcha "Desktop wizard worked great. Mobile: input keyboard covers submit button, can't click Next. Fix: scroll form into view on focus, add sticky footer for navigation buttons, test on real devices."
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 44. Ngày mai chúng ta sẽ làm Project 6: Multi-step Registration Flow - áp dụng mọi thứ đã học về wizards, forms, và validation vào một project hoàn chỉnh!