📅 NGÀY 26: useReducer - Quản Lý State Phức Tạp với Reducer Pattern
📍 Vị trí: Phase 3, Tuần 6, Ngày 26/45
⏱️ Thời lượng: 3-4 giờ
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Hiểu được reducer pattern là gì và tại sao nó quan trọng
- [ ] Viết được reducer function thuần túy (pure function)
- [ ] Sử dụng được useReducer hook để quản lý complex state
- [ ] Quyết định được khi nào dùng useState vs useReducer dựa trên trade-offs
- [ ] Thiết kế được actions và action creators chuẩn mực
🤔 Kiểm tra đầu vào (5 phút)
Trước khi bắt đầu, hãy trả lời 3 câu hỏi sau:
useState có vấn đề gì khi state logic phức tạp?
- Gợi ý: Nghĩ về form có 10+ fields, mỗi field có validation...
Pure function là gì? Cho ví dụ.
- Gợi ý: Function không side effects, same input → same output
Bạn đã từng gặp trường hợp state updates phụ thuộc vào nhau chưa?
- Ví dụ: Submit form → loading true → data fetched → loading false, data updated
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Hãy tưởng tượng bạn đang build form đăng ký user với requirements sau:
// ❌ VẤN ĐỀ: useState cho complex state
function RegistrationForm() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [touched, setTouched] = useState({});
const [validationErrors, setValidationErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
// 😱 Logic phức tạp với nhiều state updates
setIsLoading(true);
setError(null);
try {
// Validation
const errors = {};
if (!username) errors.username = 'Required';
if (!email) errors.email = 'Required';
if (password !== confirmPassword) {
errors.password = 'Passwords must match';
}
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
setIsLoading(false); // ⚠️ Dễ quên!
return;
}
// API call
await registerUser({ username, email, password });
// Success - reset form
setUsername('');
setEmail('');
setPassword('');
setConfirmPassword('');
setTouched({});
setValidationErrors({});
setIsLoading(false);
} catch (err) {
setError(err.message);
setIsLoading(false); // ⚠️ Duplicate logic!
}
};
// ... 😰 Còn handleBlur, handleChange, etc.
}Vấn đề:
- 🔴 Quá nhiều useState → khó tracking
- 🔴 State updates rải rác → dễ miss, duplicate logic
- 🔴 Khó test → phải mock từng setter
- 🔴 Khó debug → không biết state thay đổi bởi action nào
1.2 Giải Pháp: Reducer Pattern
Reducer Pattern là pattern tập trung tất cả state logic vào 1 chỗ:
Current State + Action → Reducer → Next StateCore Idea:
// Thay vì:
setUsername('john');
setEmail('john@example.com');
setIsLoading(true);
// Ta có:
dispatch({ type: 'UPDATE_FIELD', field: 'username', value: 'john' });
dispatch({ type: 'UPDATE_FIELD', field: 'email', value: 'john@example.com' });
dispatch({ type: 'SUBMIT_START' });Lợi ích:
- ✅ Centralized logic → tất cả state transitions ở 1 chỗ
- ✅ Predictable → same action + same state = same result
- ✅ Testable → test reducer như function thuần
- ✅ Debuggable → log actions để biết "what happened"
1.3 Mental Model
┌─────────────────────────────────────────────────┐
│ COMPONENT │
│ │
│ User Event │
│ ↓ │
│ dispatch(action) ───────────────┐ │
│ ↓ │ │
│ ┌──────────────────┐ │ │
│ │ Current State │ │ │
│ │ { count: 5 } │ │ │
│ └──────────────────┘ │ │
│ │ │ │
│ ↓ ↓ │
│ ┌─────────────────────────────────────┐ │
│ │ REDUCER (Pure Function) │ │
│ │ │ │
│ │ (state, action) => { │ │
│ │ switch(action.type) { │ │
│ │ case 'INCREMENT': │ │
│ │ return { count: state.count+1 }│ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌──────────────────┐ │
│ │ Next State │ │
│ │ { count: 6 } │ │
│ └──────────────────┘ │
│ │ │
│ ↓ │
│ Re-render │
│ │
└─────────────────────────────────────────────────┘Analogy: Reducer giống như máy bán hàng tự động
- State: Số tiền trong máy
- Action: Người bấm nút "Coke"
- Reducer: Logic "nếu bấm Coke VÀ đủ tiền → trả Coke, giảm tiền"
- Next State: Tiền sau khi mua
1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Reducer chỉ dùng khi state phức tạp"
- ✅ Sự thật: Reducer giúp code dễ đọc hơn, kể cả state đơn giản
❌ Hiểu lầm 2: "Reducer phải return object mới hoàn toàn"
- ✅ Sự thật: Reducer chỉ cần return immutable update, có thể spread
❌ Hiểu lầm 3: "useReducer thay thế useState hoàn toàn"
- ✅ Sự thật: Mỗi cái có use case riêng (sẽ so sánh sau)
❌ Hiểu lầm 4: "Reducer có thể mutate state"
- ✅ Sự thật: Reducer PHẢI pure, không side effects
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Pattern Cơ Bản - Counter với useReducer ⭐
import { useReducer } from 'react';
// 1️⃣ ĐỊNH NGHĨA REDUCER
// Reducer là pure function: (currentState, action) => nextState
function counterReducer(state, action) {
// state: current state value
// action: object mô tả "what happened"
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
case 'SET':
return { count: action.payload };
default:
// ⚠️ QUAN TRỌNG: Luôn có default case
// Throw error nếu action không hợp lệ
throw new Error(`Unknown action type: ${action.type}`);
}
}
// 2️⃣ SỬ DỤNG useReducer TRONG COMPONENT
function Counter() {
// useReducer(reducer, initialState)
// Returns: [state, dispatch]
const [state, dispatch] = useReducer(
counterReducer,
{ count: 0 }, // initial state
);
return (
<div>
<h1>Count: {state.count}</h1>
{/* 3️⃣ DISPATCH ACTIONS */}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
<button onClick={() => dispatch({ type: 'SET', payload: 10 })}>
Set to 10
</button>
</div>
);
}📝 Giải thích:
Reducer Function:
- Pure function, không side effects
- Input:
(state, action) - Output: new state (immutable)
useReducer Hook:
const [state, dispatch] = useReducer(reducer, initialState)state: current state (giống useState)dispatch: function để gửi actions
Action Object:
{ type: 'ACTION_NAME' }- required{ type: 'ACTION_NAME', payload: data }- với data
🔍 So sánh useState vs useReducer:
// ❌ VỚI useState
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
<button onClick={() => setCount(10)}>Set to 10</button>
</div>
);
}
// ✅ VỚI useReducer
// Logic tập trung, dễ đọc, dễ test
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
<button onClick={() => dispatch({ type: 'SET', payload: 10 })}>
Set to 10
</button>
</div>
);
}Demo 2: Kịch Bản Thực Tế - Todo App ⭐⭐
// 📋 TODO REDUCER
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload.text,
completed: false,
},
],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo,
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
case 'SET_FILTER':
return {
...state,
filter: action.payload.filter,
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter((todo) => !todo.completed),
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🎯 COMPONENT
function TodoApp() {
const initialState = {
todos: [],
filter: 'all', // 'all' | 'active' | 'completed'
};
const [state, dispatch] = useReducer(todoReducer, initialState);
const [inputValue, setInputValue] = useState('');
// ✅ Event handlers gọi dispatch
const handleAddTodo = (e) => {
e.preventDefault();
if (inputValue.trim()) {
dispatch({
type: 'ADD_TODO',
payload: { text: inputValue },
});
setInputValue('');
}
};
const handleToggle = (id) => {
dispatch({
type: 'TOGGLE_TODO',
payload: { id },
});
};
const handleDelete = (id) => {
dispatch({
type: 'DELETE_TODO',
payload: { id },
});
};
// ✅ Derived state (computed from state)
const filteredTodos = state.todos.filter((todo) => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true; // 'all'
});
const activeCount = state.todos.filter((t) => !t.completed).length;
const completedCount = state.todos.filter((t) => t.completed).length;
return (
<div>
<h1>Todo App</h1>
{/* Form */}
<form onSubmit={handleAddTodo}>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder='What needs to be done?'
/>
<button type='submit'>Add</button>
</form>
{/* Filters */}
<div>
<button
onClick={() =>
dispatch({ type: 'SET_FILTER', payload: { filter: 'all' } })
}
disabled={state.filter === 'all'}
>
All ({state.todos.length})
</button>
<button
onClick={() =>
dispatch({ type: 'SET_FILTER', payload: { filter: 'active' } })
}
disabled={state.filter === 'active'}
>
Active ({activeCount})
</button>
<button
onClick={() =>
dispatch({ type: 'SET_FILTER', payload: { filter: 'completed' } })
}
disabled={state.filter === 'completed'}
>
Completed ({completedCount})
</button>
</div>
{/* Todo List */}
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>
<input
type='checkbox'
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.text}
</span>
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
{/* Actions */}
{completedCount > 0 && (
<button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
Clear Completed
</button>
)}
</div>
);
}🎯 Tại sao useReducer tốt hơn ở đây?
- State shape phức tạp:
{ todos: [], filter: 'all' } - Multiple related updates: Toggle todo → update todos array
- Predictable state transitions: Mỗi action có logic rõ ràng
- Easy to test: Test reducer như pure function
Demo 3: Edge Cases - Validation & Error Handling ⭐⭐⭐
// 🔐 FORM REDUCER với Validation
function formReducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD':
// ✅ Validate on field update
const errors = { ...state.errors };
// Clear error khi user sửa
if (errors[action.payload.field]) {
delete errors[action.payload.field];
}
return {
...state,
values: {
...state.values,
[action.payload.field]: action.payload.value,
},
errors,
touched: {
...state.touched,
[action.payload.field]: true,
},
};
case 'SUBMIT_START':
// ⚠️ EDGE CASE: Không submit nếu đang loading
if (state.isLoading) {
console.warn('Already submitting');
return state;
}
return {
...state,
isLoading: true,
submitError: null,
};
case 'SUBMIT_SUCCESS':
// ✅ Reset form after success
return {
values: { username: '', email: '', password: '' },
errors: {},
touched: {},
isLoading: false,
submitError: null,
};
case 'SUBMIT_ERROR':
return {
...state,
isLoading: false,
submitError: action.payload.error,
};
case 'SET_ERRORS':
// ⚠️ EDGE CASE: Validation errors
return {
...state,
errors: action.payload.errors,
isLoading: false,
};
case 'RESET_FORM':
// ✅ Reset về initial state
return action.payload.initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🎯 COMPONENT với Error Handling
function RegistrationForm() {
const initialState = {
values: {
username: '',
email: '',
password: '',
},
errors: {},
touched: {},
isLoading: false,
submitError: null,
};
const [state, dispatch] = useReducer(formReducer, initialState);
// ✅ Validation logic (pure function)
const validate = (values) => {
const errors = {};
if (!values.username) {
errors.username = 'Username is required';
} else if (values.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
if (!values.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = 'Email is invalid';
}
if (!values.password) {
errors.password = 'Password is required';
} else if (values.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
return errors;
};
const handleSubmit = async (e) => {
e.preventDefault();
// Validate
const validationErrors = validate(state.values);
if (Object.keys(validationErrors).length > 0) {
dispatch({
type: 'SET_ERRORS',
payload: { errors: validationErrors },
});
return;
}
// Submit
dispatch({ type: 'SUBMIT_START' });
try {
await registerUser(state.values);
dispatch({ type: 'SUBMIT_SUCCESS' });
alert('Registration successful!');
} catch (error) {
dispatch({
type: 'SUBMIT_ERROR',
payload: { error: error.message },
});
}
};
const handleChange = (field) => (e) => {
dispatch({
type: 'UPDATE_FIELD',
payload: {
field,
value: e.target.value,
},
});
};
return (
<form onSubmit={handleSubmit}>
{/* Username */}
<div>
<input
type='text'
value={state.values.username}
onChange={handleChange('username')}
placeholder='Username'
/>
{state.touched.username && state.errors.username && (
<span style={{ color: 'red' }}>{state.errors.username}</span>
)}
</div>
{/* Email */}
<div>
<input
type='email'
value={state.values.email}
onChange={handleChange('email')}
placeholder='Email'
/>
{state.touched.email && state.errors.email && (
<span style={{ color: 'red' }}>{state.errors.email}</span>
)}
</div>
{/* Password */}
<div>
<input
type='password'
value={state.values.password}
onChange={handleChange('password')}
placeholder='Password'
/>
{state.touched.password && state.errors.password && (
<span style={{ color: 'red' }}>{state.errors.password}</span>
)}
</div>
{/* Submit Error */}
{state.submitError && (
<div style={{ color: 'red', fontWeight: 'bold' }}>
{state.submitError}
</div>
)}
{/* Buttons */}
<button
type='submit'
disabled={state.isLoading}
>
{state.isLoading ? 'Submitting...' : 'Register'}
</button>
<button
type='button'
onClick={() =>
dispatch({
type: 'RESET_FORM',
payload: { initialState },
})
}
>
Reset
</button>
</form>
);
}
// Mock API
const registerUser = (data) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (data.username === 'error') {
reject(new Error('Username already exists'));
} else {
resolve({ success: true });
}
}, 1000);
});
};🎯 Key Takeaways:
Edge cases handled:
- ✅ Prevent double submit (isLoading check)
- ✅ Clear errors on field change
- ✅ Validation before submit
- ✅ Reset form after success
State transitions rõ ràng:
- SUBMIT_START → isLoading = true
- SUBMIT_SUCCESS → reset form
- SUBMIT_ERROR → show error, isLoading = false
Pure functions:
- validate() là pure function
- Reducer không có side effects
🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Level 1: Áp Dụng Concept (15 phút)
/**
* 🎯 Mục tiêu: Chuyển đổi useState sang useReducer
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: Context, useEffect (chưa cần)
*
* Requirements:
* 1. Chuyển component dưới từ useState → useReducer
* 2. Implement 3 actions: LIKE, UNLIKE, RESET
* 3. Reducer phải có default case throw error
*
* 💡 Gợi ý:
* - Tạo reducer function trước
* - Định nghĩa action types
* - Thay useState bằng useReducer
*/
// ❌ CÁCH SAI: Không validate action type
function likeReducer(state, action) {
// 🚫 Missing default case
if (action.type === 'LIKE') {
return { likes: state.likes + 1 };
}
if (action.type === 'UNLIKE') {
return { likes: Math.max(0, state.likes - 1) };
}
// ⚠️ Typo "RESETT" sẽ không throw error!
return state; // Silent fail
}
// ✅ CÁCH ĐÚNG: Luôn có default case
function likeReducer(state, action) {
switch (action.type) {
case 'LIKE':
return { likes: state.likes + 1 };
case 'UNLIKE':
return { likes: Math.max(0, state.likes - 1) };
case 'RESET':
return { likes: 0 };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// 🎯 NHIỆM VỤ CỦA BẠN: Chuyển component này sang useReducer
function LikeButton() {
const [likes, setLikes] = useState(0);
return (
<div>
<h2>❤️ {likes} likes</h2>
<button onClick={() => setLikes(likes + 1)}>Like</button>
<button onClick={() => setLikes(Math.max(0, likes - 1))}>Unlike</button>
<button onClick={() => setLikes(0)}>Reset</button>
</div>
);
}
// TODO: Viết lại component trên với useReducer
// function LikeButton() {
// // Your code here
// }💡 Solution
/**
* LikeButton component using useReducer instead of multiple useState calls
* Manages like count with actions: LIKE, UNLIKE, RESET
*/
import { useReducer } from 'react';
// 1. Reducer function - pure, predictable state transitions
function likeReducer(state, action) {
switch (action.type) {
case 'LIKE':
return { likes: state.likes + 1 };
case 'UNLIKE':
return { likes: Math.max(0, state.likes - 1) };
case 'RESET':
return { likes: 0 };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function LikeButton() {
const [state, dispatch] = useReducer(likeReducer, { likes: 0 });
return (
<div>
<h2>❤️ {state.likes} likes</h2>
<button onClick={() => dispatch({ type: 'LIKE' })}>Like</button>
<button onClick={() => dispatch({ type: 'UNLIKE' })}>Unlike</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
export default LikeButton;
/*
Ví dụ kết quả khi tương tác:
Ban đầu: ❤️ 0 likes
Nhấn Like ×3: ❤️ 3 likes
Nhấn Unlike ×2: ❤️ 1 like
Nhấn Unlike ×2: ❤️ 0 likes (không âm)
Nhấn Reset: ❤️ 0 likes
Nhấn Unlike khi 0: vẫn ❤️ 0 likes (Math.max bảo vệ)
*/⭐⭐ Level 2: Nhận Biết Pattern (25 phút)
/**
* 🎯 Mục tiêu: Quyết định useState vs useReducer
* ⏱️ Thời gian: 25 phút
*
* Scenario: Bạn cần build Shopping Cart
*
* State cần quản lý:
* - items: [{ id, name, price, quantity }]
* - totalPrice: number
* - discountCode: string
* - discountAmount: number
*
* Actions:
* - ADD_ITEM
* - REMOVE_ITEM
* - UPDATE_QUANTITY
* - APPLY_DISCOUNT
* - CLEAR_CART
*
* 🤔 PHÂN TÍCH:
*
* Approach A: Multiple useState
* const [items, setItems] = useState([]);
* const [totalPrice, setTotalPrice] = useState(0);
* const [discountCode, setDiscountCode] = useState('');
* const [discountAmount, setDiscountAmount] = useState(0);
*
* Pros:
* - Đơn giản với state nhỏ
* - Mỗi state độc lập
*
* Cons:
* - Phải tính totalPrice manually mỗi lần items thay đổi
* - Updates rải rác, dễ sót
* - Khó sync giữa items và totalPrice
*
* Approach B: useReducer
* const [state, dispatch] = useReducer(cartReducer, initialState);
*
* Pros:
* - State updates tập trung
* - totalPrice tự động update trong reducer
* - Dễ test, dễ debug
* - Predictable state transitions
*
* Cons:
* - Boilerplate code nhiều hơn
* - Cần hiểu reducer pattern
*
* 💭 BẠN CHỌN GÌ VÀ TẠI SAO?
* (Viết 3-5 câu giải thích quyết định)
*
* Sau đó implement approach bạn chọn với:
* - Reducer function (nếu chọn useReducer)
* - Component với 3 actions: ADD_ITEM, REMOVE_ITEM, CLEAR_CART
* - Display total price
*/
// TODO: Viết giải thích + implementation💡 Solution
/**
* Shopping Cart component - Decision: use useReducer
* Lý do chọn useReducer thay vì multiple useState:
*
* 1. State có nhiều phần liên quan chặt chẽ: items → ảnh hưởng trực tiếp đến totalPrice
* 2. Cần tính toán totalPrice mỗi khi items thay đổi (ADD, REMOVE, UPDATE_QUANTITY)
* 3. Nhiều hành động phức tạp (APPLY_DISCOUNT, CLEAR_CART) cần cập nhật nhiều field cùng lúc
* 4. Logic tính toán và validation nên được tập trung trong reducer → dễ test, dễ debug
* 5. Khi app mở rộng (thêm tax, shipping, coupons, persistence), useReducer dễ mở rộng hơn
*
* Trade-off: boilerplate nhiều hơn, nhưng maintainability cao hơn rất nhiều
*/
import { useReducer, useState } from 'react';
// ================= REDUCER =================
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existingItem = state.items.find(
(item) => item.id === action.payload.id,
);
let newItems;
if (existingItem) {
// Tăng quantity nếu đã có
newItems = state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item,
);
} else {
// Thêm mới
newItems = [...state.items, { ...action.payload, quantity: 1 }];
}
return {
...state,
items: newItems,
totalPrice: calculateTotal(newItems, state.discountAmount),
};
}
case 'REMOVE_ITEM': {
const newItems = state.items.filter(
(item) => item.id !== action.payload.id,
);
return {
...state,
items: newItems,
totalPrice: calculateTotal(newItems, state.discountAmount),
};
}
case 'UPDATE_QUANTITY': {
const { id, quantity } = action.payload;
if (quantity < 1) return state; // Không cho quantity < 1
const newItems = state.items.map((item) =>
item.id === id ? { ...item, quantity } : item,
);
return {
...state,
items: newItems,
totalPrice: calculateTotal(newItems, state.discountAmount),
};
}
case 'APPLY_DISCOUNT': {
const discountAmount = action.payload.amount; // giả sử đã validate
return {
...state,
discountCode: action.payload.code,
discountAmount,
totalPrice: calculateTotal(state.items, discountAmount),
};
}
case 'CLEAR_CART':
return {
...state,
items: [],
totalPrice: 0,
discountCode: '',
discountAmount: 0,
};
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// Helper function - tính tổng tiền (có thể tách ra custom hook sau này)
function calculateTotal(items, discountAmount) {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return Math.max(0, subtotal - discountAmount);
}
// ================= COMPONENT =================
function ShoppingCart() {
const initialState = {
items: [],
totalPrice: 0,
discountCode: '',
discountAmount: 0,
};
const [state, dispatch] = useReducer(cartReducer, initialState);
const [newItemInput, setNewItemInput] = useState(''); // chỉ dùng tạm để demo
// Ví dụ item mẫu (trong thực tế sẽ từ API hoặc form)
const sampleItems = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 25 },
{ id: 3, name: 'Keyboard', price: 80 },
];
const handleAddSample = (item) => {
dispatch({ type: 'ADD_ITEM', payload: item });
};
const handleRemove = (id) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id } });
};
const handleUpdateQty = (id, quantity) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } });
};
const handleClear = () => {
dispatch({ type: 'CLEAR_CART' });
};
const handleApplyDiscount = () => {
// Giả lập discount 10% cho ví dụ
const subtotal = state.items.reduce(
(sum, i) => sum + i.price * i.quantity,
0,
);
const discount = Math.round(subtotal * 0.1);
dispatch({
type: 'APPLY_DISCOUNT',
payload: { code: 'SAVE10', amount: discount },
});
};
return (
<div>
<h2>Shopping Cart</h2>
{/* Danh sách sản phẩm mẫu để thêm */}
<div style={{ marginBottom: '1rem' }}>
<strong>Add sample items:</strong>{' '}
{sampleItems.map((item) => (
<button
key={item.id}
onClick={() => handleAddSample(item)}
style={{ marginRight: '0.5rem' }}
>
+ {item.name} (${item.price})
</button>
))}
</div>
{/* Cart items */}
{state.items.length === 0 ? (
<p>Your cart is empty.</p>
) : (
<>
<ul style={{ listStyle: 'none', padding: 0 }}>
{state.items.map((item) => (
<li
key={item.id}
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '0.5rem',
padding: '0.5rem',
borderBottom: '1px solid #eee',
}}
>
<div>
{item.name} × {item.quantity}
</div>
<div>
${(item.price * item.quantity).toFixed(2)}
<button
onClick={() => handleUpdateQty(item.id, item.quantity + 1)}
style={{ marginLeft: '1rem' }}
>
+
</button>
<button
onClick={() => handleUpdateQty(item.id, item.quantity - 1)}
disabled={item.quantity <= 1}
style={{ marginLeft: '0.5rem' }}
>
-
</button>
<button
onClick={() => handleRemove(item.id)}
style={{ marginLeft: '1rem', color: 'red' }}
>
Remove
</button>
</div>
</li>
))}
</ul>
<div style={{ marginTop: '1rem', fontWeight: 'bold' }}>
Subtotal: $
{state.items
.reduce((sum, i) => sum + i.price * i.quantity, 0)
.toFixed(2)}
</div>
{state.discountAmount > 0 && (
<div style={{ color: 'green' }}>
Discount ({state.discountCode}): -$
{state.discountAmount.toFixed(2)}
</div>
)}
<div style={{ fontSize: '1.2rem', marginTop: '0.5rem' }}>
Total: ${state.totalPrice.toFixed(2)}
</div>
<div style={{ marginTop: '1.5rem' }}>
<button
onClick={handleApplyDiscount}
disabled={state.items.length === 0}
>
Apply 10% Discount
</button>
<button
onClick={handleClear}
style={{ marginLeft: '1rem', color: 'red' }}
disabled={state.items.length === 0}
>
Clear Cart
</button>
</div>
</>
)}
</div>
);
}
export default ShoppingCart;
/*
Ví dụ kết quả khi tương tác:
1. Nhấn "+ Laptop" → items: 1 Laptop, total: $1200
2. Nhấn "+ Mouse" → items: Laptop + Mouse, total: $1225
3. Nhấn "+1" bên Laptop → quantity Laptop = 2, total: $2425
4. Nhấn "Apply 10% Discount" → discount $242.5, total: $2182.50
5. Nhấn "Clear Cart" → giỏ hàng trống, total: $0
*/⭐⭐⭐ Level 3: Kịch Bản Thực Tế (40 phút)
/**
* 🎯 Mục tiêu: Build Multi-Step Form với useReducer
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là user, tôi muốn điền form đăng ký 3 bước để tạo account"
*
* ✅ Acceptance Criteria:
* - [ ] Step 1: Personal Info (name, email)
* - [ ] Step 2: Address (street, city, zipcode)
* - [ ] Step 3: Review & Submit
* - [ ] Có nút Next/Previous để navigate
* - [ ] Validate mỗi step trước khi Next
* - [ ] Hiển thị progress (Step 1/3, 2/3, 3/3)
* - [ ] Submit ở step cuối
*
* 🎨 Technical Constraints:
* - Dùng useReducer cho state management
* - State shape: { currentStep, formData, errors, isSubmitting }
* - Actions: NEXT_STEP, PREV_STEP, UPDATE_FIELD, SET_ERRORS, SUBMIT_START, SUBMIT_SUCCESS
*
* 🚨 Edge Cases cần handle:
* - Không Next nếu có validation errors
* - Không Previous từ step 1
* - Không submit nếu đang isSubmitting
* - Clear errors khi user sửa field
*
* 📝 Implementation Checklist:
* - [ ] Reducer với 6 actions
* - [ ] Validation function cho mỗi step
* - [ ] StepIndicator component
* - [ ] Form fields cho 3 steps
* - [ ] Navigation buttons với disabled states
*/
// TODO: Implement Multi-Step Form
// Gợi ý State Shape:
const initialState = {
currentStep: 1,
formData: {
// Step 1
name: '',
email: '',
// Step 2
street: '',
city: '',
zipcode: '',
},
errors: {},
isSubmitting: false,
};
// Gợi ý Reducer:
// function multiStepReducer(state, action) {
// switch (action.type) {
// case 'NEXT_STEP': ...
// case 'PREV_STEP': ...
// case 'UPDATE_FIELD': ...
// case 'SET_ERRORS': ...
// case 'SUBMIT_START': ...
// case 'SUBMIT_SUCCESS': ...
// }
// }💡 Solution
/**
* Multi-Step Registration Form using useReducer
* 3 steps: Personal Info → Address → Review & Submit
* Handles validation per step, navigation, and submission flow
*/
import { useReducer, useState } from 'react';
// ================= STATE & TYPES =================
const initialState = {
currentStep: 1,
formData: {
// Step 1
name: '',
email: '',
// Step 2
street: '',
city: '',
zipcode: '',
},
errors: {},
isSubmitting: false,
};
// ================= VALIDATION =================
const validateStep = (step, values) => {
const errors = {};
if (step === 1) {
if (!values.name.trim()) {
errors.name = 'Name is required';
} else if (values.name.trim().length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (!values.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = 'Please enter a valid email';
}
}
if (step === 2) {
if (!values.street.trim()) {
errors.street = 'Street address is required';
}
if (!values.city.trim()) {
errors.city = 'City is required';
}
if (!values.zipcode.trim()) {
errors.zipcode = 'Zip code is required';
} else if (!/^\d{5}(-\d{4})?$/.test(values.zipcode)) {
errors.zipcode =
'Please enter a valid zip code (e.g. 12345 or 12345-6789)';
}
}
return errors;
};
// ================= REDUCER =================
function multiStepReducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD': {
const { field, value } = action.payload;
// Clear error for this field when user types
const newErrors = { ...state.errors };
if (newErrors[field]) delete newErrors[field];
return {
...state,
formData: {
...state.formData,
[field]: value,
},
errors: newErrors,
};
}
case 'SET_ERRORS':
return {
...state,
errors: action.payload.errors,
};
case 'NEXT_STEP': {
const errors = validateStep(state.currentStep, state.formData);
if (Object.keys(errors).length > 0) {
return {
...state,
errors,
};
}
if (state.currentStep < 3) {
return {
...state,
currentStep: state.currentStep + 1,
errors: {},
};
}
return state;
}
case 'PREV_STEP':
if (state.currentStep > 1) {
return {
...state,
currentStep: state.currentStep - 1,
errors: {},
};
}
return state;
case 'SUBMIT_START':
if (state.isSubmitting) return state;
return {
...state,
isSubmitting: true,
errors: {},
};
case 'SUBMIT_SUCCESS':
return {
...initialState,
currentStep: 4, // success page
};
case 'SUBMIT_ERROR':
return {
...state,
isSubmitting: false,
errors: { submit: action.payload.message },
};
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// ================= COMPONENT =================
function MultiStepForm() {
const [state, dispatch] = useReducer(multiStepReducer, initialState);
const handleChange = (field) => (e) => {
dispatch({
type: 'UPDATE_FIELD',
payload: { field, value: e.target.value },
});
};
const handleNext = () => {
dispatch({ type: 'NEXT_STEP' });
};
const handlePrev = () => {
dispatch({ type: 'PREV_STEP' });
};
const handleSubmit = async (e) => {
e.preventDefault();
// Final validation (though already checked on step 2 → next)
const finalErrors = validateStep(2, state.formData);
if (Object.keys(finalErrors).length > 0) {
dispatch({ type: 'SET_ERRORS', payload: { errors: finalErrors } });
return;
}
dispatch({ type: 'SUBMIT_START' });
// Simulate API call
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
// Uncomment to test error:
// throw new Error('Server is down');
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({
type: 'SUBMIT_ERROR',
payload: { message: err.message || 'Something went wrong' },
});
}
};
const StepIndicator = () => (
<div style={{ marginBottom: '2rem', textAlign: 'center' }}>
<strong>Step {state.currentStep} of 3</strong>
<div style={{ marginTop: '0.5rem' }}>
{[1, 2, 3].map((step) => (
<span
key={step}
style={{
display: 'inline-block',
width: '30px',
height: '30px',
lineHeight: '30px',
borderRadius: '50%',
background: state.currentStep >= step ? '#4CAF50' : '#ddd',
color: 'white',
margin: '0 8px',
fontWeight: 'bold',
}}
>
{step}
</span>
))}
</div>
</div>
);
if (state.currentStep === 4) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h2>Registration Successful! 🎉</h2>
<p>Thank you for signing up.</p>
<p>Name: {state.formData.name}</p>
<p>Email: {state.formData.email}</p>
<p>
Address: {state.formData.street}, {state.formData.city}{' '}
{state.formData.zipcode}
</p>
</div>
);
}
return (
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '2rem' }}>
<h1>Register</h1>
<StepIndicator />
<form onSubmit={handleSubmit}>
{state.currentStep === 1 && (
<>
<h3>Personal Information</h3>
<div style={{ marginBottom: '1rem' }}>
<label>Name:</label>
<input
type='text'
value={state.formData.name}
onChange={handleChange('name')}
placeholder='John Doe'
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
/>
{state.errors.name && (
<span style={{ color: 'red' }}>{state.errors.name}</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label>Email:</label>
<input
type='email'
value={state.formData.email}
onChange={handleChange('email')}
placeholder='john@example.com'
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
/>
{state.errors.email && (
<span style={{ color: 'red' }}>{state.errors.email}</span>
)}
</div>
</>
)}
{state.currentStep === 2 && (
<>
<h3>Address</h3>
<div style={{ marginBottom: '1rem' }}>
<label>Street:</label>
<input
type='text'
value={state.formData.street}
onChange={handleChange('street')}
placeholder='123 Main St'
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
/>
{state.errors.street && (
<span style={{ color: 'red' }}>{state.errors.street}</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label>City:</label>
<input
type='text'
value={state.formData.city}
onChange={handleChange('city')}
placeholder='New York'
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
/>
{state.errors.city && (
<span style={{ color: 'red' }}>{state.errors.city}</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label>Zip Code:</label>
<input
type='text'
value={state.formData.zipcode}
onChange={handleChange('zipcode')}
placeholder='10001'
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
/>
{state.errors.zipcode && (
<span style={{ color: 'red' }}>{state.errors.zipcode}</span>
)}
</div>
</>
)}
{state.currentStep === 3 && (
<>
<h3>Review & Submit</h3>
<div style={{ marginBottom: '1rem' }}>
<strong>Name:</strong> {state.formData.name || '—'}
</div>
<div style={{ marginBottom: '1rem' }}>
<strong>Email:</strong> {state.formData.email || '—'}
</div>
<div style={{ marginBottom: '1rem' }}>
<strong>Address:</strong> {state.formData.street || '—'},{' '}
{state.formData.city || '—'} {state.formData.zipcode || '—'}
</div>
{state.errors.submit && (
<div style={{ color: 'red', margin: '1rem 0' }}>
{state.errors.submit}
</div>
)}
</>
)}
<div
style={{
marginTop: '2rem',
display: 'flex',
gap: '1rem',
justifyContent: 'space-between',
}}
>
<button
type='button'
onClick={handlePrev}
disabled={state.currentStep === 1 || state.isSubmitting}
style={{ padding: '10px 20px', minWidth: '100px' }}
>
Previous
</button>
{state.currentStep < 3 ? (
<button
type='button'
onClick={handleNext}
disabled={state.isSubmitting}
style={{
padding: '10px 20px',
minWidth: '100px',
background: '#4CAF50',
color: 'white',
}}
>
Next
</button>
) : (
<button
type='submit'
disabled={state.isSubmitting}
style={{
padding: '10px 20px',
minWidth: '120px',
background: state.isSubmitting ? '#999' : '#2196F3',
color: 'white',
}}
>
{state.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
</div>
</form>
</div>
);
}
export default MultiStepForm;
/*
Ví dụ kết quả khi tương tác:
1. Step 1 → nhập name & email hợp lệ → Next → Step 2
2. Step 2 → nhập address hợp lệ → Next → Step 3 (Review)
3. Step 3 → thấy toàn bộ thông tin → Submit
→ isSubmitting = true (nút disable)
→ sau 1.5s → Success screen với dữ liệu đã nhập
4. Trường hợp lỗi:
- Step 1 thiếu name → Next → hiển thị lỗi đỏ, không chuyển step
- Submit thất bại (nếu throw error) → hiển thị thông báo lỗi
*/⭐⭐⭐⭐ Level 4: Quyết Định Kiến Trúc (60 phút)
/**
* 🎯 Mục tiêu: Thiết kế State Management cho Complex Feature
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Context: Bạn đang build Kanban Board (như Trello)
*
* Requirements:
* - 3 columns: Todo, In Progress, Done
* - Mỗi column có nhiều cards
* - Drag & drop cards giữa columns
* - Add/Edit/Delete cards
* - Filter cards by tag
* - Search cards by title
*
* State Shape Options:
*
* Option A: Normalized State
* {
* columns: { 'todo': {...}, 'inProgress': {...}, 'done': {...} },
* cards: { 'card1': {...}, 'card2': {...} },
* columnOrder: ['todo', 'inProgress', 'done'],
* filter: { tag: null, searchText: '' }
* }
*
* Option B: Nested State
* {
* columns: [
* { id: 'todo', title: 'Todo', cards: [...] },
* { id: 'inProgress', title: 'In Progress', cards: [...] },
* { id: 'done', title: 'Done', cards: [...] }
* ],
* filter: { tag: null, searchText: '' }
* }
*
* Option C: Separate Reducers
* - columnsReducer
* - cardsReducer
* - filterReducer
* (Combine với combineReducers pattern - tự research)
*
* Nhiệm vụ:
* 1. So sánh 3 approaches
* 2. Document pros/cons
* 3. Chọn approach tốt nhất
* 4. Viết ADR
*
* 📝 ADR Template:
*
* ## Context
* Kanban Board cần quản lý columns, cards, filters. Drag & drop yêu cầu
* cập nhật positions nhanh...
*
* ## Decision
* Chọn Option A: Normalized State
*
* ## Rationale
* - Performance: Update card không cần clone entire column
* - Flexibility: Dễ reference card từ multiple places
* - Scalability: Dễ add features (card comments, attachments)
*
* ## Consequences
* Trade-offs:
* + Fast updates
* + Easy to find card by ID
* - More complex queries (need to join data)
* - Need utility functions to denormalize
*
* ## Alternatives Considered
* - Option B: Simpler but slow for large datasets
* - Option C: Over-engineering for this scale
*
* 💻 PHASE 2: Implementation (30 phút)
* Implement reducer với approach đã chọn
* - At least 5 actions: MOVE_CARD, ADD_CARD, DELETE_CARD, UPDATE_FILTER, CLEAR_FILTER
* - Helper functions nếu cần (denormalize, etc.)
*
* 🧪 PHASE 3: Testing (10 phút)
* Manual test cases:
* - [ ] Move card from Todo to In Progress
* - [ ] Move card back
* - [ ] Delete card updates column
* - [ ] Filter by tag
* - [ ] Search by title
*/
// TODO: Viết ADR + Implementation💡 Solution
/**
* Kanban Board Architecture Decision & Implementation using useReducer
* Level 4: Quyết Định Kiến Trúc - Chọn Normalized State (Option A)
*
* ## Context
* Xây dựng Kanban Board giống Trello với drag & drop, add/edit/delete cards,
* filter by tag, search by title. Yêu cầu performance tốt khi có nhiều cards,
* dễ update vị trí khi drag, và dễ mở rộng (comments, attachments, assignees).
*
* ## Decision
* Chọn Option A: Normalized State (flat structure)
* {
* columns: { [columnId]: { id, title, cardIds: string[] } },
* cards: { [cardId]: { id, title, description, tags, columnId } },
* columnOrder: string[],
* filters: { tag: null | string, searchText: '' }
* }
*
* ## Rationale
* - Performance: Khi drag & drop chỉ cần cập nhật cardIds array của 2 columns → không clone toàn bộ state
* - Flexibility: Dễ truy xuất card bằng ID từ bất kỳ đâu (filter, search, edit)
* - Scalability: Dễ thêm fields cho card (comments, dueDate, attachments) mà không làm nested state sâu
* - Drag & drop: Chỉ cần thay đổi cardIds và columnId → immutable update nhanh
* - Filter/Search: Duyệt cards object thay vì nested arrays → dễ implement
*
* ## Consequences (Trade-offs)
* + Update nhanh, không cần deep clone
* + Dễ tìm card theo ID
* + Dễ serialize cho localStorage hoặc API
* - Cần helper functions để "denormalize" khi render (kết hợp cards + columns)
* - Query phức tạp hơn một chút so với nested (nhưng chấp nhận được)
*
* ## Alternatives Considered
* - Option B (Nested): Dễ đọc ban đầu, nhưng drag & drop chậm khi clone arrays lớn
* - Option C (Separate Reducers): Over-engineering cho feature này, phức tạp hơn cần thiết
*/
/**
* Kanban Board component with normalized state + useReducer
*/
import { useReducer } from 'react';
// ================= TYPES & INITIAL STATE =================
const initialState = {
columns: {
todo: { id: 'todo', title: 'Todo', cardIds: [] },
inprogress: { id: 'inprogress', title: 'In Progress', cardIds: [] },
done: { id: 'done', title: 'Done', cardIds: [] },
},
cards: {},
columnOrder: ['todo', 'inprogress', 'done'],
filters: {
tag: null, // null = all
searchText: '',
},
};
// ================= HELPER FUNCTIONS =================
const denormalizeColumn = (state, columnId) => {
const column = state.columns[columnId];
const cards = column.cardIds
.map((id) => state.cards[id])
.filter((card) => {
if (state.filters.searchText) {
return card.title
.toLowerCase()
.includes(state.filters.searchText.toLowerCase());
}
if (state.filters.tag) {
return card.tags?.includes(state.filters.tag);
}
return true;
});
return { ...column, cards };
};
const getFilteredColumns = (state) => {
return state.columnOrder.map((colId) => denormalizeColumn(state, colId));
};
// ================= REDUCER =================
function kanbanReducer(state, action) {
switch (action.type) {
case 'ADD_CARD': {
const { title, columnId } = action.payload;
const cardId = `card-${Date.now()}`;
const newCard = {
id: cardId,
title,
description: '',
tags: [],
columnId,
};
return {
...state,
cards: {
...state.cards,
[cardId]: newCard,
},
columns: {
...state.columns,
[columnId]: {
...state.columns[columnId],
cardIds: [...state.columns[columnId].cardIds, cardId],
},
},
};
}
case 'DELETE_CARD': {
const { cardId } = action.payload;
const card = state.cards[cardId];
if (!card) return state;
const { [cardId]: removed, ...remainingCards } = state.cards;
const column = state.columns[card.columnId];
const newCardIds = column.cardIds.filter((id) => id !== cardId);
return {
...state,
cards: remainingCards,
columns: {
...state.columns,
[card.columnId]: {
...column,
cardIds: newCardIds,
},
},
};
}
case 'MOVE_CARD': {
const { cardId, sourceColumnId, targetColumnId, newIndex } =
action.payload;
const card = state.cards[cardId];
if (!card) return state;
// Remove from source
const sourceColumn = state.columns[sourceColumnId];
const sourceCardIds = sourceColumn.cardIds.filter((id) => id !== cardId);
// Add to target at new position
const targetColumn = state.columns[targetColumnId];
const targetCardIds = [...targetColumn.cardIds];
targetCardIds.splice(newIndex, 0, cardId);
return {
...state,
cards: {
...state.cards,
[cardId]: { ...card, columnId: targetColumnId },
},
columns: {
...state.columns,
[sourceColumnId]: { ...sourceColumn, cardIds: sourceCardIds },
[targetColumnId]: { ...targetColumn, cardIds: targetCardIds },
},
};
}
case 'UPDATE_FILTER': {
return {
...state,
filters: {
...state.filters,
...action.payload,
},
};
}
case 'CLEAR_FILTER':
return {
...state,
filters: { tag: null, searchText: '' },
};
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// ================= COMPONENT =================
function KanbanBoard() {
const [state, dispatch] = useReducer(kanbanReducer, initialState);
const filteredColumns = getFilteredColumns(state);
const handleAddCard = (columnId) => {
const title = prompt('Enter card title:');
if (title?.trim()) {
dispatch({
type: 'ADD_CARD',
payload: { title: title.trim(), columnId },
});
}
};
const handleDeleteCard = (cardId) => {
if (window.confirm('Delete this card?')) {
dispatch({ type: 'DELETE_CARD', payload: { cardId } });
}
};
// Drag & Drop handlers (giả lập bằng buttons cho demo)
const handleMoveCard = (cardId, sourceColumnId, targetColumnId, index) => {
dispatch({
type: 'MOVE_CARD',
payload: { cardId, sourceColumnId, targetColumnId, newIndex: index },
});
};
const handleSearch = (e) => {
dispatch({
type: 'UPDATE_FILTER',
payload: { searchText: e.target.value },
});
};
const handleFilterTag = (tag) => {
dispatch({
type: 'UPDATE_FILTER',
payload: { tag: tag || null },
});
};
return (
<div style={{ padding: '2rem' }}>
<h1>Kanban Board</h1>
{/* Controls */}
<div
style={{
marginBottom: '2rem',
display: 'flex',
gap: '1rem',
alignItems: 'center',
}}
>
<input
type='text'
placeholder='Search cards...'
onChange={handleSearch}
style={{ padding: '8px', flex: 1, maxWidth: '300px' }}
/>
<select
onChange={(e) => handleFilterTag(e.target.value)}
defaultValue=''
>
<option value=''>All Tags</option>
<option value='urgent'>Urgent</option>
<option value='feature'>Feature</option>
<option value='bug'>Bug</option>
</select>
<button onClick={() => dispatch({ type: 'CLEAR_FILTER' })}>
Clear Filters
</button>
</div>
{/* Board */}
<div style={{ display: 'flex', gap: '1.5rem', overflowX: 'auto' }}>
{filteredColumns.map((column) => (
<div
key={column.id}
style={{
background: '#f4f5f7',
borderRadius: '8px',
padding: '1rem',
minWidth: '300px',
}}
>
<h3>
{column.title} ({column.cards.length})
</h3>
<button
onClick={() => handleAddCard(column.id)}
style={{ marginBottom: '1rem', width: '100%' }}
>
+ Add Card
</button>
<div style={{ minHeight: '200px' }}>
{column.cards.map((card, index) => (
<div
key={card.id}
style={{
background: 'white',
padding: '1rem',
marginBottom: '0.8rem',
borderRadius: '6px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<strong>{card.title}</strong>
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem' }}>
Tags: {card.tags?.join(', ') || '—'}
</div>
<div
style={{
marginTop: '0.8rem',
display: 'flex',
gap: '0.5rem',
}}
>
<button
onClick={() => handleDeleteCard(card.id)}
style={{ color: 'red', fontSize: '0.9rem' }}
>
Delete
</button>
{/* Drag & drop demo buttons */}
{column.id !== 'todo' && (
<button
onClick={() =>
handleMoveCard(card.id, column.id, 'todo', 0)
}
style={{ fontSize: '0.9rem' }}
>
← To Todo
</button>
)}
{column.id !== 'done' && (
<button
onClick={() =>
handleMoveCard(card.id, column.id, 'done', 0)
}
style={{ fontSize: '0.9rem' }}
>
→ To Done
</button>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
export default KanbanBoard;
/*
Ví dụ kết quả khi tương tác:
1. Nhấn "+ Add Card" ở Todo → nhập "Implement login" → card xuất hiện ở Todo
2. Nhấn "→ To Done" → card di chuyển sang Done column
3. Gõ "login" vào search → chỉ hiển thị card có "login" trong title
4. Chọn tag "urgent" → chỉ hiển thị cards có tag urgent
5. Nhấn "Delete" → card biến mất, column card count giảm
6. Nhấn "Clear Filters" → trở lại hiển thị tất cả
*/⭐⭐⭐⭐⭐ Level 5: Production Challenge (90 phút)
/**
* 🎯 Mục tiêu: Build Production-Ready Feature
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
*
* Build "E-commerce Product Listing với Advanced Filters"
*
* Features:
* 1. Display products grid (from API)
* 2. Filters:
* - Category (Electronics, Clothing, Books)
* - Price range (slider)
* - Rating (1-5 stars)
* - In stock only (checkbox)
* 3. Sorting:
* - Price: Low to High / High to Low
* - Rating: High to Low
* - Name: A-Z
* 4. Pagination:
* - 12 items per page
* - Next/Previous buttons
* 5. Search by product name
* 6. URL sync (filters should be in URL query params)
*
* 🏗️ Technical Design Doc:
*
* 1. Component Architecture:
* - ProductListing (container)
* - FilterPanel
* - ProductGrid
* - ProductCard
* - Pagination
*
* 2. State Management Strategy:
* - useReducer cho filter state
* - useState cho products data (hoặc useReducer)
* - Decision: Tại sao?
*
* 3. API Integration:
* - Mock API: https://fakestoreapi.com/products
* - Client-side filtering (API không support filters)
*
* 4. Performance Considerations:
* - useMemo cho filtered/sorted products
* - Debounce search input
* - Lazy load images
*
* 5. Error Handling Strategy:
* - API errors
* - No results state
* - Loading states
*
* ✅ Production Checklist:
* - [ ] TypeScript types (optional, nhưng document types)
* - [ ] Error boundaries (optional, nhưng mention)
* - [ ] Loading states (skeleton UI)
* - [ ] Empty states ("No products found")
* - [ ] Error states ("Failed to load")
* - [ ] Mobile responsive (basic)
* - [ ] Accessibility:
* - [ ] Keyboard navigation
* - [ ] ARIA labels cho filters
* - [ ] Focus management
* - [ ] Performance:
* - [ ] Memoize expensive calculations
* - [ ] Debounce search
* - [ ] Code quality:
* - [ ] Reducer tests (pseudo-code OK)
* - [ ] Helper functions documented
* - [ ] Clear naming
*
* 📝 Documentation:
* - README với setup instructions
* - Reducer actions documentation
* - State shape documentation
*
* 🔍 Code Review Self-Checklist:
* - [ ] Reducer is pure function
* - [ ] No missing default case
* - [ ] Actions have clear names
* - [ ] State updates are immutable
* - [ ] Edge cases handled
* - [ ] Comments cho complex logic
*/
// TODO: Full implementation
// Starter Code:
// Mock API call
const fetchProducts = async () => {
const response = await fetch('https://fakestoreapi.com/products');
return response.json();
};
// State Shape (gợi ý):
const initialState = {
filters: {
category: 'all',
priceRange: [0, 1000],
rating: 0,
inStock: false,
searchText: '',
},
sort: {
field: 'name', // 'name' | 'price' | 'rating'
order: 'asc', // 'asc' | 'desc'
},
pagination: {
currentPage: 1,
itemsPerPage: 12,
},
};
// Reducer Actions:
// - SET_CATEGORY
// - SET_PRICE_RANGE
// - SET_RATING
// - TOGGLE_IN_STOCK
// - SET_SEARCH
// - SET_SORT
// - SET_PAGE
// - RESET_FILTERS💡 Solution
/**
* E-commerce Product Listing with Advanced Filters & Pagination
* Production-ready version using useReducer for filter + sort + pagination state
* Client-side filtering & sorting from Fake Store API
*/
import { useReducer, useEffect, useState, useMemo, useCallback } from 'react';
// ================= TYPES & INITIAL STATE =================
const initialState = {
products: [],
filteredProducts: [],
filters: {
category: 'all',
priceRange: [0, 1000],
rating: 0,
inStock: false, // FakeStore has no real stock → simulate with random
searchText: '',
},
sort: {
field: 'title', // 'title' | 'price' | 'rating'
order: 'asc', // 'asc' | 'desc'
},
pagination: {
currentPage: 1,
itemsPerPage: 12,
},
loading: true,
error: null,
};
// ================= REDUCER =================
function productReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return {
...state,
products: action.payload,
loading: false,
error: null,
};
case 'FETCH_ERROR':
return {
...state,
loading: false,
error: action.payload,
};
case 'SET_CATEGORY':
case 'SET_PRICE_RANGE':
case 'SET_RATING':
case 'TOGGLE_IN_STOCK':
case 'SET_SEARCH':
case 'SET_SORT':
case 'SET_PAGE':
return {
...state,
[action.payload.key]: action.payload.value,
pagination:
action.type === 'SET_PAGE'
? { ...state.pagination, currentPage: action.payload.value }
: state.pagination,
};
case 'RESET_FILTERS':
return {
...state,
filters: initialState.filters,
sort: initialState.sort,
pagination: { ...state.pagination, currentPage: 1 },
};
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// ================= HELPER: APPLY FILTERS & SORT =================
const applyFiltersAndSort = (products, filters, sort) => {
let result = [...products];
// Search
if (filters.searchText) {
const search = filters.searchText.toLowerCase();
result = result.filter(
(p) =>
p.title.toLowerCase().includes(search) ||
p.description.toLowerCase().includes(search),
);
}
// Category
if (filters.category !== 'all') {
result = result.filter((p) => p.category === filters.category);
}
// Price range
result = result.filter(
(p) => p.price >= filters.priceRange[0] && p.price <= filters.priceRange[1],
);
// Rating
if (filters.rating > 0) {
result = result.filter((p) => p.rating.rate >= filters.rating);
}
// In stock (simulated)
if (filters.inStock) {
result = result.filter(() => Math.random() > 0.3); // ~70% in stock
}
// Sorting
result.sort((a, b) => {
let aVal =
sort.field === 'title'
? a.title
: sort.field === 'price'
? a.price
: a.rating.rate;
let bVal =
sort.field === 'title'
? b.title
: sort.field === 'price'
? b.price
: b.rating.rate;
if (typeof aVal === 'string') {
return sort.order === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return sort.order === 'asc' ? aVal - bVal : bVal - aVal;
});
return result;
};
// ================= COMPONENT =================
function ProductListing() {
const [state, dispatch] = useReducer(productReducer, initialState);
const [debouncedSearch, setDebouncedSearch] = useState(
state.filters.searchText,
);
// Fetch products
useEffect(() => {
const fetchProducts = async () => {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch('https://fakestoreapi.com/products');
if (!res.ok) throw new Error('Failed to fetch products');
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
};
fetchProducts();
}, []);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
dispatch({
type: 'SET_SEARCH',
payload: {
key: 'filters',
value: { ...state.filters, searchText: debouncedSearch },
},
});
}, 400);
return () => clearTimeout(timer);
}, [debouncedSearch]);
// Computed filtered & sorted products
const processedProducts = useMemo(() => {
return applyFiltersAndSort(state.products, state.filters, state.sort);
}, [state.products, state.filters, state.sort]);
// Pagination slice
const paginatedProducts = useMemo(() => {
const start =
(state.pagination.currentPage - 1) * state.pagination.itemsPerPage;
return processedProducts.slice(
start,
start + state.pagination.itemsPerPage,
);
}, [processedProducts, state.pagination]);
const totalPages = Math.ceil(
processedProducts.length / state.pagination.itemsPerPage,
);
// Handlers
const handleCategoryChange = (e) => {
dispatch({
type: 'SET_CATEGORY',
payload: {
key: 'filters',
value: { ...state.filters, category: e.target.value },
},
});
dispatch({ type: 'SET_PAGE', payload: { key: 'pagination', value: 1 } });
};
const handlePriceChange = (e, bound) => {
const newRange = [...state.filters.priceRange];
newRange[bound] = Number(e.target.value);
dispatch({
type: 'SET_PRICE_RANGE',
payload: {
key: 'filters',
value: { ...state.filters, priceRange: newRange },
},
});
dispatch({ type: 'SET_PAGE', payload: { key: 'pagination', value: 1 } });
};
const handleRatingChange = (e) => {
dispatch({
type: 'SET_RATING',
payload: {
key: 'filters',
value: { ...state.filters, rating: Number(e.target.value) },
},
});
dispatch({ type: 'SET_PAGE', payload: { key: 'pagination', value: 1 } });
};
const toggleInStock = () => {
dispatch({
type: 'TOGGLE_IN_STOCK',
payload: {
key: 'filters',
value: { ...state.filters, inStock: !state.filters.inStock },
},
});
dispatch({ type: 'SET_PAGE', payload: { key: 'pagination', value: 1 } });
};
const handleSearchChange = (e) => {
setDebouncedSearch(e.target.value);
};
const handleSortChange = (field) => {
const newSort = {
field,
order:
state.sort.field === field && state.sort.order === 'asc'
? 'desc'
: 'asc',
};
dispatch({
type: 'SET_SORT',
payload: { key: 'sort', value: newSort },
});
};
const goToPage = (page) => {
if (page >= 1 && page <= totalPages) {
dispatch({
type: 'SET_PAGE',
payload: { key: 'pagination', value: page },
});
}
};
if (state.loading)
return (
<div style={{ textAlign: 'center', padding: '4rem' }}>
Loading products...
</div>
);
if (state.error)
return (
<div style={{ color: 'red', textAlign: 'center', padding: '4rem' }}>
Error: {state.error}
</div>
);
return (
<div style={{ padding: '2rem', maxWidth: '1400px', margin: '0 auto' }}>
<h1>Products</h1>
{/* Filters Panel */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '1.5rem',
marginBottom: '2rem',
background: '#f9f9f9',
padding: '1.5rem',
borderRadius: '8px',
}}
>
<div>
<label>Search</label>
<input
type='text'
value={debouncedSearch}
onChange={handleSearchChange}
placeholder='Search by name or description...'
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
/>
</div>
<div>
<label>Category</label>
<select
value={state.filters.category}
onChange={handleCategoryChange}
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
>
<option value='all'>All Categories</option>
<option value="men's clothing">Men's Clothing</option>
<option value="women's clothing">Women's Clothing</option>
<option value='jewelery'>Jewelry</option>
<option value='electronics'>Electronics</option>
</select>
</div>
<div>
<label>Min Price: ${state.filters.priceRange[0]}</label>
<input
type='range'
min='0'
max='1000'
value={state.filters.priceRange[0]}
onChange={(e) => handlePriceChange(e, 0)}
style={{ width: '100%' }}
/>
</div>
<div>
<label>Max Price: ${state.filters.priceRange[1]}</label>
<input
type='range'
min='0'
max='1000'
value={state.filters.priceRange[1]}
onChange={(e) => handlePriceChange(e, 1)}
style={{ width: '100%' }}
/>
</div>
<div>
<label>Minimum Rating</label>
<select
value={state.filters.rating}
onChange={handleRatingChange}
style={{ width: '100%', padding: '8px', marginTop: '4px' }}
>
<option value={0}>Any</option>
{[1, 2, 3, 4].map((r) => (
<option
key={r}
value={r}
>
{r}+ Stars
</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type='checkbox'
checked={state.filters.inStock}
onChange={toggleInStock}
id='instock'
/>
<label htmlFor='instock'>In Stock Only (simulated)</label>
</div>
<div style={{ gridColumn: '1 / -1', textAlign: 'right' }}>
<button
onClick={() => dispatch({ type: 'RESET_FILTERS' })}
style={{
padding: '8px 16px',
background: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
Reset Filters
</button>
</div>
</div>
{/* Sort Controls */}
<div
style={{
marginBottom: '1.5rem',
display: 'flex',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<strong>Sort by:</strong>
{['title', 'price', 'rating'].map((field) => (
<button
key={field}
onClick={() => handleSortChange(field)}
style={{
padding: '6px 12px',
background: state.sort.field === field ? '#3498db' : '#ecf0f1',
color: state.sort.field === field ? 'white' : 'black',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{field.charAt(0).toUpperCase() + field.slice(1)}
{state.sort.field === field &&
(state.sort.order === 'asc' ? ' ↑' : ' ↓')}
</button>
))}
</div>
{/* Products Grid */}
{paginatedProducts.length === 0 ? (
<div
style={{ textAlign: 'center', padding: '4rem', fontSize: '1.2rem' }}
>
No products match your filters.
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '1.5rem',
}}
>
{paginatedProducts.map((product) => (
<div
key={product.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
overflow: 'hidden',
background: 'white',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<img
src={product.image}
alt={product.title}
style={{
width: '100%',
height: '220px',
objectFit: 'contain',
padding: '1rem',
}}
loading='lazy'
/>
<div style={{ padding: '1rem' }}>
<h3 style={{ fontSize: '1.1rem', margin: '0 0 0.5rem' }}>
{product.title}
</h3>
<div
style={{
fontWeight: 'bold',
color: '#e67e22',
marginBottom: '0.5rem',
}}
>
${product.price.toFixed(2)}
</div>
<div style={{ fontSize: '0.9rem', color: '#7f8c8d' }}>
Rating: {product.rating.rate} ★ ({product.rating.count})
</div>
<div
style={{
fontSize: '0.85rem',
marginTop: '0.5rem',
color: '#95a5a6',
}}
>
{product.category}
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div
style={{
marginTop: '2rem',
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
flexWrap: 'wrap',
}}
>
<button
onClick={() => goToPage(state.pagination.currentPage - 1)}
disabled={state.pagination.currentPage === 1}
style={{ padding: '8px 16px', minWidth: '80px' }}
>
Previous
</button>
{[...Array(totalPages)].map((_, i) => {
const page = i + 1;
return (
<button
key={page}
onClick={() => goToPage(page)}
style={{
padding: '8px 12px',
background:
state.pagination.currentPage === page
? '#3498db'
: '#ecf0f1',
color:
state.pagination.currentPage === page ? 'white' : 'black',
border: 'none',
borderRadius: '4px',
minWidth: '40px',
}}
>
{page}
</button>
);
})}
<button
onClick={() => goToPage(state.pagination.currentPage + 1)}
disabled={state.pagination.currentPage === totalPages}
style={{ padding: '8px 16px', minWidth: '80px' }}
>
Next
</button>
</div>
)}
{/* Results count */}
<div style={{ textAlign: 'center', marginTop: '1rem', color: '#7f8c8d' }}>
Showing {paginatedProducts.length} of {processedProducts.length}{' '}
products
</div>
</div>
);
}
export default ProductListing;
/*
Ví dụ kết quả khi tương tác:
1. Trang load → hiển thị 12 sản phẩm đầu tiên
2. Gõ "ring" vào search → chỉ hiển thị sản phẩm có "ring" trong title/description
3. Chọn category "jewelery" → chỉ jewelry, search vẫn hoạt động
4. Kéo min price lên 100 → lọc sản phẩm >= $100
5. Chọn min rating 4 → chỉ sản phẩm >= 4 sao
6. Nhấn sort "Price" → sắp xếp tăng dần, nhấn lại → giảm dần
7. Chuyển sang page 2 → hiển thị sản phẩm 13-24
8. Nhấn Reset Filters → trở về trạng thái ban đầu
*/📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh Trade-offs: useState vs useReducer
| Tiêu chí | useState | useReducer | Winner |
|---|---|---|---|
| Setup Complexity | ✅ Đơn giản, 1 dòng | ❌ Cần reducer function + actions | useState |
| Code Length | ✅ Ít code hơn | ❌ Nhiều boilerplate | useState |
| State Complexity | ❌ Khó quản lý khi >3 related states | ✅ Tốt với complex state | useReducer |
| Logic Centralization | ❌ Logic rải rác trong handlers | ✅ Logic tập trung trong reducer | useReducer |
| Testability | ❌ Phải test component | ✅ Test reducer như pure function | useReducer |
| Debugging | ❌ Khó track "what happened" | ✅ Log actions = clear history | useReducer |
| Performance | ✅ Nhẹ hơn (ít overhead) | ⚠️ Overhead nhỏ (thường không đáng kể) | useState |
| Learning Curve | ✅ Dễ học | ❌ Cần hiểu reducer pattern | useState |
| Type Safety | ⚠️ Dễ typo setter names | ✅ Action types dễ type-check | useReducer |
| Refactoring | ❌ Khó refactor khi app grows | ✅ Dễ add actions mới | useReducer |
Decision Tree: Khi nào dùng cái nào?
START: Cần quản lý state?
│
├─ State đơn giản (1-2 primitives)?
│ └─ YES → useState ✅
│ Example: toggle, counter, input value
│
├─ State là object nhỏ, không có logic phức tạp?
│ └─ YES → useState ✅
│ Example: { isOpen: false, selectedId: null }
│
├─ State updates phụ thuộc vào current state?
│ └─ YES
│ │
│ ├─ Chỉ 1-2 dependencies?
│ │ └─ useState với functional updates ✅
│ │ Example: setCount(prev => prev + 1)
│ │
│ └─ Multiple complex dependencies?
│ └─ useReducer ✅
│ Example: Form với nhiều fields + validation
│
├─ Cần update nhiều related states cùng lúc?
│ └─ YES → useReducer ✅
│ Example: Fetch data → update loading, data, error
│
├─ State transitions có logic rõ ràng (state machine)?
│ └─ YES → useReducer ✅
│ Example: Todo: idle → loading → success/error
│
├─ Cần dễ test logic riêng biệt khỏi UI?
│ └─ YES → useReducer ✅
│ Test reducer function độc lập
│
├─ Team đã quen với Redux pattern?
│ └─ YES → useReducer ✅
│ Familiar pattern, easy onboarding
│
└─ NOT SURE?
└─ Start with useState
If it gets messy → Refactor to useReducerReal-World Use Cases
✅ useState - Perfect for:
- Toggle/Boolean states:
const [isOpen, setIsOpen] = useState(false);- Simple input fields:
const [email, setEmail] = useState('');- Independent states:
const [count, setCount] = useState(0);
const [name, setName] = useState('');✅ useReducer - Perfect for:
- Forms với nhiều fields:
// Thay vì 10 useState, dùng 1 useReducer
const [formState, dispatch] = useReducer(formReducer, initialFormState);- Data fetching với loading/error:
// State: { data, loading, error }
// Actions: FETCH_START, FETCH_SUCCESS, FETCH_ERROR- Shopping cart, todo list, etc:
// Complex state với nhiều operations
// Mỗi operation = 1 action- State machines:
// idle → loading → success/error
// Mỗi transition rõ ràng🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Infinite Re-renders 🐛
// ❌ CODE BỊ LỖI
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
// 🐛 BUG: Infinite loop!
dispatch({ type: 'INCREMENT' });
return <div>{state.count}</div>;
}❓ Câu hỏi:
- Tại sao code trên gây infinite loop?
- Làm sao fix?
💡 Giải thích:
dispatchtrigger re-render- Component re-render →
dispatchlại được gọi - → Re-render lại → infinite loop!
✅ Fix:
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
// ✅ Chỉ dispatch trong event handler hoặc useEffect
const handleClick = () => {
dispatch({ type: 'INCREMENT' });
};
return (
<div>
{state.count}
<button onClick={handleClick}>+1</button>
</div>
);
}Bug 2: Mutating State 🐛
// ❌ CODE BỊ LỖI
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
// 🐛 BUG: Mutate state trực tiếp!
state.todos.push(action.payload);
return state;
case 'TOGGLE_TODO':
// 🐛 BUG: Mutate nested object!
const todo = state.todos.find((t) => t.id === action.payload.id);
todo.completed = !todo.completed;
return state;
default:
return state;
}
}❓ Câu hỏi:
- Tại sao code trên sai?
- Hậu quả là gì?
- Làm sao fix?
💡 Giải thích:
- Reducer PHẢI pure function
- Mutate state → React không detect change → không re-render
- Hoặc re-render nhưng UI không sync với state
✅ Fix:
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
// ✅ Return new array
return {
...state,
todos: [...state.todos, action.payload],
};
case 'TOGGLE_TODO':
// ✅ Return new array với new object
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo,
),
};
default:
return state;
}
}Bug 3: Missing Default Case 🐛
// ❌ CODE BỊ LỖI
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
// 🐛 BUG: No default case!
}
}
// Sử dụng:
dispatch({ type: 'RESET' }); // Typo: should be 'RESET'
// → Không throw error, silent fail!
// → Trả về undefined → App crash❓ Câu hỏi:
- Tại sao cần default case?
- Nên làm gì trong default case?
💡 Giải thích:
- Typo action type → silent fail
- Return undefined → React error "Cannot read property of undefined"
- Khó debug vì không biết action nào gây lỗi
✅ Fix:
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
// ✅ Luôn có default case
default:
// Throw error rõ ràng
throw new Error(`Unknown action type: ${action.type}`);
// HOẶC log warning và return state
// console.warn(`Unknown action: ${action.type}`);
// return state;
}
}✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
Đánh dấu ✅ khi bạn tự tin với concept:
Reducer Basics:
- [ ] Tôi hiểu reducer pattern là gì
- [ ] Tôi biết cách viết reducer function
- [ ] Tôi hiểu reducer phải pure (no side effects, no mutations)
- [ ] Tôi biết cách định nghĩa actions
useReducer Hook:
- [ ] Tôi biết syntax:
const [state, dispatch] = useReducer(reducer, initialState) - [ ] Tôi hiểu dispatch là gì và cách dùng
- [ ] Tôi biết khi nào dùng useState vs useReducer
- [ ] Tôi có thể refactor từ useState sang useReducer
Best Practices:
- [ ] Tôi luôn có default case trong reducer
- [ ] Tôi không mutate state trong reducer
- [ ] Tôi đặt tên actions rõ ràng (SCREAMING_SNAKE_CASE)
- [ ] Tôi hiểu trade-offs của useReducer
Edge Cases:
- [ ] Tôi biết cách handle validation errors
- [ ] Tôi biết cách prevent double dispatch (isLoading check)
- [ ] Tôi biết cách reset state về initial state
Code Review Checklist
Review code của bạn:
Reducer Function:
- [ ] Pure function (không side effects)
- [ ] Immutable updates (không mutate state)
- [ ] Có default case throw error
- [ ] Action types rõ ràng, consistent naming
State Shape:
- [ ] Flat khi có thể (avoid deep nesting)
- [ ] Grouped related data
- [ ] Không có redundant data (derive khi cần)
Actions:
- [ ] Type names descriptive
- [ ] Payload structure consistent
- [ ] Document payload shape (comments hoặc TS)
Component:
- [ ] Dispatch trong event handlers/useEffect (không trong render)
- [ ] Error handling
- [ ] Loading states
- [ ] Edge cases covered
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Bài 1: Refactor useState to useReducer
Cho component sau dùng useState:
function Calculator() {
const [display, setDisplay] = useState('0');
const [operator, setOperator] = useState(null);
const [previousValue, setPreviousValue] = useState(null);
const [waitingForOperand, setWaitingForOperand] = useState(false);
const inputDigit = (digit) => {
if (waitingForOperand) {
setDisplay(String(digit));
setWaitingForOperand(false);
} else {
setDisplay(display === '0' ? String(digit) : display + digit);
}
};
const performOperation = (nextOperator) => {
const inputValue = parseFloat(display);
if (previousValue === null) {
setPreviousValue(inputValue);
} else if (operator) {
const currentValue = previousValue || 0;
const newValue = performCalculation[operator](currentValue, inputValue);
setDisplay(String(newValue));
setPreviousValue(newValue);
}
setWaitingForOperand(true);
setOperator(nextOperator);
};
// ... rest of logic
}Nhiệm vụ:
- Chuyển sang useReducer
- Định nghĩa actions: INPUT_DIGIT, SET_OPERATOR, CALCULATE, CLEAR
- Viết reducer handle tất cả logic
💡 Solution
/**
* Calculator component refactored from multiple useState to useReducer
* Manages calculator state with actions: INPUT_DIGIT, INPUT_DECIMAL, SET_OPERATOR, CALCULATE, CLEAR, DELETE
*/
import { useReducer } from 'react';
// ================= REDUCER =================
function calculatorReducer(state, action) {
switch (action.type) {
case 'INPUT_DIGIT': {
const { digit } = action.payload;
if (state.waitingForOperand) {
return {
...state,
display: String(digit),
waitingForOperand: false,
};
}
if (state.display === '0') {
return {
...state,
display: String(digit),
};
}
return {
...state,
display: state.display + digit,
};
}
case 'INPUT_DECIMAL': {
if (state.waitingForOperand) {
return {
...state,
display: '0.',
waitingForOperand: false,
};
}
if (!state.display.includes('.')) {
return {
...state,
display: state.display + '.',
};
}
return state; // already has decimal
}
case 'SET_OPERATOR': {
const { operator } = action.payload;
const inputValue = parseFloat(state.display);
if (state.previousValue === null) {
// First operand
return {
...state,
previousValue: inputValue,
operator,
waitingForOperand: true,
};
}
if (state.operator && !state.waitingForOperand) {
// Chain operations
const currentValue = state.previousValue || 0;
const newValue = performCalculation[state.operator](
currentValue,
inputValue,
);
return {
...state,
display: String(newValue),
previousValue: newValue,
operator,
waitingForOperand: true,
};
}
return {
...state,
operator,
waitingForOperand: true,
};
}
case 'CALCULATE': {
if (state.previousValue === null || state.operator === null) {
return state;
}
const inputValue = parseFloat(state.display);
const currentValue = state.previousValue || 0;
const newValue = performCalculation[state.operator](
currentValue,
inputValue,
);
return {
...state,
display: String(newValue),
previousValue: null,
operator: null,
waitingForOperand: true,
};
}
case 'CLEAR':
return {
display: '0',
operator: null,
previousValue: null,
waitingForOperand: false,
};
case 'DELETE': {
if (state.waitingForOperand) return state;
if (state.display.length <= 1) {
return {
...state,
display: '0',
};
}
return {
...state,
display: state.display.slice(0, -1),
};
}
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// Calculation functions
const performCalculation = {
'/': (prev, next) => prev / next,
'*': (prev, next) => prev * next,
'+': (prev, next) => prev + next,
'-': (prev, next) => prev - next,
};
// ================= COMPONENT =================
function Calculator() {
const initialState = {
display: '0',
operator: null,
previousValue: null,
waitingForOperand: false,
};
const [state, dispatch] = useReducer(calculatorReducer, initialState);
const handleDigit = (digit) => {
dispatch({ type: 'INPUT_DIGIT', payload: { digit } });
};
const handleDecimal = () => {
dispatch({ type: 'INPUT_DECIMAL' });
};
const handleOperator = (op) => {
dispatch({ type: 'SET_OPERATOR', payload: { operator: op } });
};
const handleEquals = () => {
dispatch({ type: 'CALCULATE' });
};
const handleClear = () => {
dispatch({ type: 'CLEAR' });
};
const handleDelete = () => {
dispatch({ type: 'DELETE' });
};
return (
<div
style={{
maxWidth: '320px',
margin: '2rem auto',
border: '1px solid #ccc',
borderRadius: '12px',
overflow: 'hidden',
background: '#f8f9fa',
}}
>
{/* Display */}
<div
style={{
background: '#333',
color: 'white',
fontSize: '2.8rem',
textAlign: 'right',
padding: '1.5rem 1rem',
minHeight: '80px',
fontFamily: 'monospace',
}}
>
{state.display}
</div>
{/* Buttons */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '1px',
background: '#ddd',
}}
>
<button
onClick={handleClear}
style={buttonStyle('orange')}
>
C
</button>
<button
onClick={handleDelete}
style={buttonStyle('gray')}
>
←
</button>
<button
onClick={() => handleOperator('/')}
style={buttonStyle('gray')}
>
/
</button>
<button
onClick={() => handleDigit(7)}
style={buttonStyle()}
>
7
</button>
<button
onClick={() => handleDigit(8)}
style={buttonStyle()}
>
8
</button>
<button
onClick={() => handleDigit(9)}
style={buttonStyle()}
>
9
</button>
<button
onClick={() => handleOperator('*')}
style={buttonStyle('gray')}
>
×
</button>
<button
onClick={() => handleDigit(4)}
style={buttonStyle()}
>
4
</button>
<button
onClick={() => handleDigit(5)}
style={buttonStyle()}
>
5
</button>
<button
onClick={() => handleDigit(6)}
style={buttonStyle()}
>
6
</button>
<button
onClick={() => handleOperator('-')}
style={buttonStyle('gray')}
>
-
</button>
<button
onClick={() => handleDigit(1)}
style={buttonStyle()}
>
1
</button>
<button
onClick={() => handleDigit(2)}
style={buttonStyle()}
>
2
</button>
<button
onClick={() => handleDigit(3)}
style={buttonStyle()}
>
3
</button>
<button
onClick={() => handleOperator('+')}
style={buttonStyle('gray')}
>
+
</button>
<button
onClick={() => handleDigit(0)}
style={{ ...buttonStyle(), gridColumn: 'span 2' }}
>
0
</button>
<button
onClick={handleDecimal}
style={buttonStyle()}
>
.
</button>
<button
onClick={handleEquals}
style={buttonStyle('orange')}
>
=
</button>
</div>
</div>
);
}
// Helper for consistent button styling
const buttonStyle = (bg = '#fff') => ({
padding: '1.5rem',
fontSize: '1.5rem',
border: 'none',
background:
bg === 'orange' ? '#f39c12' : bg === 'gray' ? '#7f8c8d' : '#ecf0f1',
color: bg === 'orange' || bg === 'gray' ? 'white' : '#2c3e50',
cursor: 'pointer',
transition: 'background 0.1s',
':hover': { background: bg === 'orange' ? '#e67e22' : '#dfe6e9' },
});
export default Calculator;
/*
Ví dụ kết quả khi tương tác:
1. Nhấn 5 → hiển thị "5"
2. Nhấn + → lưu 5 làm previousValue, operator = '+'
3. Nhấn 3 → hiển thị "3"
4. Nhấn = → tính 5 + 3 = 8, hiển thị "8"
5. Nhấn × → operator = '*', waitingForOperand = true
6. Nhấn 4 → hiển thị "4"
7. Nhấn = → tính 8 × 4 = 32, hiển thị "32"
8. Nhấn C → reset về "0"
9. Nhấn 1 2 3 . 4 5 → hiển thị "123.45"
10. Nhấn ← (delete) → "123.4"
*/Nâng cao (60 phút)
Bài 2: Build Undo/Redo với useReducer
Requirements:
- Tạo drawing canvas đơn giản (grid of clickable cells)
- User click cell → toggle color
- Có nút Undo (quay lại 1 bước)
- Có nút Redo (làm lại bước vừa undo)
- Disable Undo khi không có history
- Disable Redo khi không có future
State shape gợi ý:
{
past: [state1, state2, ...], // Array of previous states
present: currentState, // Current state
future: [state3, state4, ...] // Array of undone states
}Actions:
- DRAW (toggle cell color)
- UNDO
- REDO
- CLEAR
Tham khảo pattern: Time Travel Pattern
💡 Solution
/**
* Simple Pixel Drawing Canvas with Undo/Redo using useReducer
* - Grid of clickable cells (10×10)
* - Click to toggle cell color (black/white)
* - Undo / Redo buttons with history
* - Clear button
*/
import { useReducer } from 'react';
// ================= TYPES & INITIAL STATE =================
const GRID_SIZE = 10;
const createEmptyGrid = () =>
Array(GRID_SIZE)
.fill()
.map(() => Array(GRID_SIZE).fill(false));
const initialState = {
past: [],
present: createEmptyGrid(),
future: [],
};
// ================= REDUCER =================
function drawingReducer(state, action) {
switch (action.type) {
case 'TOGGLE_CELL': {
const { row, col } = action.payload;
// Save current state to past before change
const newPast = [...state.past, state.present];
// Create new grid with toggled cell
const newGrid = state.present.map((r, i) =>
i === row ? r.map((cell, j) => (j === col ? !cell : cell)) : r,
);
return {
past: newPast,
present: newGrid,
future: [], // Clear future when new action occurs
};
}
case 'UNDO': {
if (state.past.length === 0) return state;
const previous = state.past[state.past.length - 1];
const newPast = state.past.slice(0, -1);
return {
past: newPast,
present: previous,
future: [state.present, ...state.future],
};
}
case 'REDO': {
if (state.future.length === 0) return state;
const next = state.future[0];
const newFuture = state.future.slice(1);
return {
past: [...state.past, state.present],
present: next,
future: newFuture,
};
}
case 'CLEAR': {
return {
past: [],
present: createEmptyGrid(),
future: [],
};
}
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
// ================= COMPONENT =================
function DrawingCanvas() {
const [state, dispatch] = useReducer(drawingReducer, initialState);
const handleCellClick = (row, col) => {
dispatch({ type: 'TOGGLE_CELL', payload: { row, col } });
};
const handleUndo = () => dispatch({ type: 'UNDO' });
const handleRedo = () => dispatch({ type: 'REDO' });
const handleClear = () => dispatch({ type: 'CLEAR' });
const canUndo = state.past.length > 0;
const canRedo = state.future.length > 0;
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h2>Pixel Art - Undo/Redo Demo</h2>
{/* Controls */}
<div
style={{
marginBottom: '1.5rem',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
}}
>
<button
onClick={handleUndo}
disabled={!canUndo}
style={{
padding: '10px 20px',
background: canUndo ? '#3498db' : '#bdc3c7',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: canUndo ? 'pointer' : 'not-allowed',
}}
>
Undo
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
style={{
padding: '10px 20px',
background: canRedo ? '#2ecc71' : '#bdc3c7',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: canRedo ? 'pointer' : 'not-allowed',
}}
>
Redo
</button>
<button
onClick={handleClear}
style={{
padding: '10px 20px',
background: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '6px',
}}
>
Clear
</button>
</div>
{/* Canvas Grid */}
<div
style={{
display: 'inline-grid',
gridTemplateColumns: `repeat(${GRID_SIZE}, 40px)`,
gridTemplateRows: `repeat(${GRID_SIZE}, 40px)`,
gap: '2px',
background: '#ecf0f1',
padding: '8px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
{state.present.map((row, rowIndex) =>
row.map((isFilled, colIndex) => (
<div
key={`${rowIndex}-${colIndex}`}
onClick={() => handleCellClick(rowIndex, colIndex)}
style={{
width: '40px',
height: '40px',
backgroundColor: isFilled ? '#2c3e50' : 'white',
border: '1px solid #bdc3c7',
borderRadius: '3px',
cursor: 'pointer',
transition: 'background-color 0.12s',
}}
/>
)),
)}
</div>
{/* Status */}
<div style={{ marginTop: '1rem', color: '#7f8c8d' }}>
History: {state.past.length} past • {state.future.length} future
</div>
</div>
);
}
export default DrawingCanvas;
/*
Ví dụ kết quả khi tương tác:
1. Click vài ô → các ô chuyển thành đen
2. Nhấn Undo → quay lại trạng thái trước đó (các ô trắng trở lại)
3. Nhấn Redo → khôi phục thay đổi vừa undo
4. Vẽ nhiều bước → Undo nhiều lần → Redo nhiều lần
5. Nhấn Clear → canvas trắng, past và future reset
6. Không thể Undo khi past rỗng → nút disabled
7. Không thể Redo khi future rỗng → nút disabled
*/📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - useReducer:
- https://react.dev/reference/react/useReducer
- Đọc kỹ phần "Examples" và "Troubleshooting"
When to use useReducer:
- https://react.dev/learn/extracting-state-logic-into-a-reducer
- So sánh useState vs useReducer
Đọc thêm
Redux Style Guide (reducer best practices):
- https://redux.js.org/style-guide/style-guide
- Phần "Write Reducers" áp dụng cho useReducer
Immer for Immutable Updates:
- https://immerjs.github.io/immer/
- (Chưa dùng hôm nay, nhưng useful sau này)
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (Đã học)
Ngày 11-12: useState basics + patterns
- useReducer là "useState on steroids"
- Cùng purpose: quản lý state
- Khác approach: centralized vs distributed
Ngày 13: Forms với State
- useReducer làm form logic cleaner
- 1 reducer thay vì nhiều useState
Ngày 14: Lifting State Up
- useReducer giúp avoid prop drilling
- Kết hợp Context (Ngày sau) = global state
Hướng tới (Sẽ học)
Ngày 27: useReducer Advanced Patterns
- State normalization
- Reducer composition
- Async actions pattern
Ngày 28: useReducer + useEffect
- Data fetching với reducer
- Loading/error states pattern
Ngày 29: Custom Hooks với useReducer
- useAsync, useForm
- Reusable state machines
Ngày 40+: Context + useReducer
- Global state management
- Alternative to Redux
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Khi nào KHÔNG nên dùng useReducer:
// ❌ Over-engineering: State đơn giản
const [isOpen, setIsOpen] = useState(false);
// Không cần:
// const [state, dispatch] = useReducer(modalReducer, { isOpen: false });2. Action naming conventions:
// ✅ GOOD: Descriptive, present tense, SCREAMING_SNAKE_CASE
'ADD_TODO';
'TOGGLE_TODO';
'DELETE_TODO';
'SET_FILTER';
// ❌ BAD: Vague, unclear
'UPDATE'; // Update cái gì?
'change'; // Không consistent casing
'todoToggled'; // Past tense confusing3. State shape design:
// ❌ BAD: Deeply nested
{
user: {
profile: {
settings: {
theme: 'dark'
}
}
}
}
// ✅ GOOD: Flat structure
{
userTheme: 'dark',
userProfile: {...},
userSettings: {...}
}4. Performance:
useReducer có overhead nhỏ so với useState, nhưng:
- Thường không đáng kể
- Trade-off xứng đáng cho code quality
- Profile before optimizing!
Câu Hỏi Phỏng Vấn
Junior Level:
Q: "useState và useReducer khác nhau như thế nào?"
Expected Answer:
- useState: Đơn giản, cho state đơn giản
- useReducer: Phức tạp hơn, cho complex state logic
- useReducer centralize logic trong reducer
- Trade-offs: boilerplate vs maintainability
Q: "Reducer function cần tuân thủ quy tắc gì?"
Expected Answer:
- Pure function (same input → same output)
- No side effects
- Immutable updates
- Luôn return state (hoặc throw error)
Mid Level:
Q: "Khi nào nên dùng useReducer thay vì useState?"
Expected Answer:
- Multiple related state values
- Next state depends on previous state
- Complex state logic
- Easier testing requirements
- Cần log/debug state transitions
Q: "Làm sao handle async operations với useReducer?"
Expected Answer:
- useReducer chỉ handle synchronous updates
- Async logic trong useEffect hoặc event handlers
- Dispatch actions: START, SUCCESS, ERROR
- Pattern sẽ học ở Ngày 28
Senior Level:
Q: "So sánh useReducer với Redux. Khi nào dùng cái nào?"
Expected Answer:
- useReducer: Component-level state, đơn giản
- Redux: Global state, middleware, devtools, time-travel
- useReducer + Context ≈ mini Redux
- Start with useReducer, migrate to Redux if needed
- Trade-off: Simplicity vs Features
War Stories
Story 1: Form Hell → useReducer Heaven
"Tôi từng maintain form đăng ký có 20+ fields với useState. Mỗi lần thêm validation rule, tôi phải update 5-6 places. Bugs xuất hiện liên tục. Khi refactor sang useReducer, tất cả logic ở 1 chỗ. Add validation? Chỉ sửa reducer. Test? Test reducer như function thuần. Dev time giảm 50%." - Senior Engineer
Story 2: Debug với Action Logs
"Production bug: Shopping cart đôi khi mất items. Với useState không biết state thay đổi ở đâu. Sau khi chuyển useReducer, tôi thêm logger middleware log tất cả actions. Phát hiện race condition trong 5 phút. useReducer giúp debugging predictable hơn rất nhiều." - Tech Lead
Story 3: Premature Optimization
"Junior dev trong team nghĩ useReducer 'professional' hơn, refactor TẤT CẢ useState sang useReducer. Kết quả? Code phức tạp không cần thiết, onboarding mới khó khăn. Lesson: Dùng đúng tool cho đúng job. Simple state = useState. Complex state = useReducer." - Engineering Manager
🎯 PREVIEW NGÀY MAI
Ngày 27: useReducer - Advanced Patterns
Bạn sẽ học:
- ✨ Complex state logic với nested data
- ✨ State normalization (flat structure)
- ✨ Reducer composition pattern
- ✨ Action creators & action types constants
- ✨ Payload structures best practices
Chuẩn bị:
- Hoàn thành bài tập hôm nay
- Review immutable updates (spread, map, filter)
- Suy nghĩ về cấu trúc state phức tạp
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 26!
Bạn giờ đã hiểu:
- ✅ Reducer pattern
- ✅ useReducer hook
- ✅ Khi nào dùng useState vs useReducer
- ✅ Best practices & common pitfalls
Keep coding! 💪