📅 NGÀY 6: useState Mastery
🎯 Mục tiêu hôm nay
- Hiểu sâu về useState hook
- Lazy initialization
- Functional updates
- State immutability
- Best practices và patterns
- Tránh những lỗi phổ biến
📚 PHẦN 1: LÝ THUYẾT (30-45 phút)
1.1. useState Cơ Bản
Syntax và Cách Dùng
jsx
import { useState } from 'react';
function Counter() {
// [state, setState] = useState(initialValue)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}Giải thích:
useState(0)- khởi tạo state với giá trị 0- Trả về array với 2 elements:
[giá trị hiện tại, hàm để update] - Destructuring để lấy ra:
const [count, setCount] = ... - Naming convention:
[thing, setThing]
Multiple State Variables
jsx
function UserProfile() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [email, setEmail] = useState('');
const [isActive, setIsActive] = useState(true);
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input
type='number'
value={age}
onChange={(e) => setAge(e.target.value)}
/>
<input
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type='checkbox'
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
</div>
);
}⚠️ Quy tắc quan trọng:
- Hooks phải ở top level của component
- Không được trong if/loop/nested function
- Thứ tự hooks phải giống nhau mỗi lần render
jsx
// ❌ SAI - Trong điều kiện
function BadComponent() {
if (someCondition) {
const [count, setCount] = useState(0); // ❌ Lỗi!
}
}
// ❌ SAI - Trong loop
function BadComponent() {
for (let i = 0; i < 5; i++) {
const [count, setCount] = useState(0); // ❌ Lỗi!
}
}
// ✅ ĐÚNG - Top level
function GoodComponent() {
const [count, setCount] = useState(0);
if (someCondition) {
// Dùng count ở đây OK
}
}1.2. Các Kiểu Dữ Liệu State
Primitives (Number, String, Boolean)
jsx
function Examples() {
const [count, setCount] = useState(0); // Number
const [text, setText] = useState(''); // String
const [isOpen, setIsOpen] = useState(false); // Boolean
const [user, setUser] = useState(null); // Null
const [data, setData] = useState(undefined); // Undefined
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? 'Đóng' : 'Mở'}
</button>
</div>
);
}Objects
jsx
function UserForm() {
const [user, setUser] = useState({
name: '',
email: '',
age: 0,
});
// ❌ SAI - Mutation trực tiếp
const updateNameWrong = (newName) => {
user.name = newName; // ❌ Không trigger re-render!
setUser(user);
};
// ✅ ĐÚNG - Tạo object mới
const updateName = (newName) => {
setUser({
...user, // Spread existing properties
name: newName, // Override name
});
};
// ✅ ĐÚNG - Update nhiều fields
const updateUser = (updates) => {
setUser({
...user,
...updates,
});
};
return (
<div>
<input
value={user.name}
onChange={(e) => setUser({ ...user, name: e.target.value })}
placeholder='Tên'
/>
<input
value={user.email}
onChange={(e) => setUser({ ...user, email: e.target.value })}
placeholder='Email'
/>
<input
type='number'
value={user.age}
onChange={(e) =>
setUser({ ...user, age: parseInt(e.target.value) })
}
placeholder='Tuổi'
/>
</div>
);
}Arrays
jsx
function TodoList() {
const [todos, setTodos] = useState([]);
// ✅ Thêm item mới
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
// Hoặc: setTodos(todos.concat({ id: Date.now(), text, completed: false }));
};
// ✅ Xóa item
const deleteTodo = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
// ✅ Update item
const toggleTodo = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
// ✅ Insert vào vị trí cụ thể
const insertAt = (index, item) => {
setTodos([...todos.slice(0, index), item, ...todos.slice(index)]);
};
// ✅ Sort
const sortTodos = () => {
setTodos([...todos].sort((a, b) => a.text.localeCompare(b.text)));
};
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type='checkbox'
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
<button onClick={() => deleteTodo(todo.id)}>Xóa</button>
</li>
))}
</ul>
);
}⚠️ Array Methods - Mutating vs Non-mutating:
jsx
// ❌ Mutating (KHÔNG dùng với setState)
push() // Thêm vào cuối
pop() // Xóa cuối
shift() // Xóa đầu
unshift() // Thêm vào đầu
splice() // Xóa/thêm tại vị trí
sort() // Sắp xếp
reverse() // Đảo ngược
// ✅ Non-mutating (AN TOÀN)
concat() // Nối arrays
slice() // Copy một phần
filter() // Lọc
map() // Transform
spread [...]// Copy array1.3. Lazy Initialization
Khi initial state cần tính toán phức tạp, dùng function để tránh chạy lại mỗi render.
jsx
// ❌ KHÔNG TỐT - expensiveCalculation chạy mỗi render
function Component() {
const [data, setData] = useState(expensiveCalculation());
// expensiveCalculation() chạy mỗi lần component re-render!
}
// ✅ TỐT - Chỉ chạy lần đầu
function Component() {
const [data, setData] = useState(() => expensiveCalculation());
// Function chỉ chạy khi mount lần đầu
}Ví dụ thực tế:
jsx
function TodoApp() {
// ❌ Đọc localStorage mỗi render
const [todos, setTodos] = useState(
JSON.parse(localStorage.getItem('todos') || '[]')
);
// ✅ Chỉ đọc localStorage một lần
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
// ✅ Initial state phức tạp
const [user, setUser] = useState(() => {
const stored = localStorage.getItem('user');
if (stored) {
const parsed = JSON.parse(stored);
// Validate và transform data
return {
...parsed,
lastLogin: new Date(parsed.lastLogin),
preferences: parsed.preferences || {},
};
}
return null;
});
}Khi nào dùng lazy initialization:
- ✅ Đọc từ localStorage/sessionStorage
- ✅ Tính toán phức tạp (parsing, computation)
- ✅ Tạo objects/arrays lớn
- ❌ KHÔNG cần cho giá trị đơn giản (0, '', false, [])
1.4. Functional Updates
Khi state mới phụ thuộc vào state cũ, dùng functional update.
jsx
function Counter() {
const [count, setCount] = useState(0);
// ❌ Có thể bị stale closure
const increment = () => {
setCount(count + 1);
};
// ❌ KHÔNG hoạt động như mong đợi
const incrementTwice = () => {
setCount(count + 1); // count = 0 → 1
setCount(count + 1); // count vẫn = 0 → 1 (không phải 2!)
};
// ✅ ĐÚNG - Functional update
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
// ✅ Bây giờ increment twice hoạt động đúng
const incrementTwice = () => {
setCount((prev) => prev + 1); // 0 → 1
setCount((prev) => prev + 1); // 1 → 2 ✅
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={incrementTwice}>+2</button>
</div>
);
}Tại sao cần functional updates:
jsx
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// ❌ count luôn là 0 (stale closure)
setCount(count + 1);
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps - count không update
// ✅ ĐÚNG
useEffect(() => {
const interval = setInterval(() => {
setCount((prev) => prev + 1); // Luôn có giá trị mới nhất
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>Count: {count}</div>;
}Với Objects và Arrays:
jsx
function TodoList() {
const [todos, setTodos] = useState([]);
// ✅ Functional update với array
const addTodo = (text) => {
setTodos((prevTodos) => [
...prevTodos,
{ id: Date.now(), text, completed: false },
]);
};
// ✅ Functional update với object
const [user, setUser] = useState({ name: '', age: 0 });
const updateAge = (increment) => {
setUser((prevUser) => ({
...prevUser,
age: prevUser.age + increment,
}));
};
}1.5. State Immutability - Tính Bất Biến
React dựa vào reference comparison để detect changes. Phải tạo object/array MỚI!
Tại sao cần immutability?
jsx
const [user, setUser] = useState({ name: 'John', age: 30 });
// ❌ SAI - Mutation
user.age = 31;
setUser(user); // React: "Object giống nhau, không re-render!"
// ✅ ĐÚNG - Tạo object mới
setUser({ ...user, age: 31 }); // React: "Object khác, re-render!"Immutable Updates - Objects
jsx
const [person, setPerson] = useState({
name: 'John',
address: {
city: 'Hanoi',
street: 'Nguyen Trai',
},
hobbies: ['reading', 'coding'],
});
// ✅ Update top-level property
setPerson({ ...person, name: 'Jane' });
// ✅ Update nested property
setPerson({
...person,
address: {
...person.address,
city: 'HCMC',
},
});
// ✅ Update array trong object
setPerson({
...person,
hobbies: [...person.hobbies, 'gaming'],
});
// ✅ Deep nested update
setPerson({
...person,
address: {
...person.address,
coordinates: {
...person.address.coordinates,
lat: 21.0285,
},
},
});Immutable Updates - Arrays
jsx
const [items, setItems] = useState([
{ id: 1, name: 'Item 1', tags: ['a', 'b'] },
{ id: 2, name: 'Item 2', tags: ['c', 'd'] },
]);
// ✅ Update item property
setItems(
items.map((item) => (item.id === 1 ? { ...item, name: 'Updated' } : item))
);
// ✅ Update nested array
setItems(
items.map((item) =>
item.id === 1 ? { ...item, tags: [...item.tags, 'new-tag'] } : item
)
);
// ✅ Xóa item
setItems(items.filter((item) => item.id !== 1));
// ✅ Insert item tại vị trí
const insertAtIndex = (array, index, item) => [
...array.slice(0, index),
item,
...array.slice(index),
];
setItems(insertAtIndex(items, 1, { id: 3, name: 'New Item' }));Helper Functions cho Immutable Updates
jsx
// Helper: Update object property
const updateObject = (obj, updates) => ({
...obj,
...updates,
});
// Helper: Update nested property
const updateNested = (obj, path, value) => {
const keys = path.split('.');
const lastKey = keys.pop();
const updated = { ...obj };
let current = updated;
for (const key of keys) {
current[key] = { ...current[key] };
current = current[key];
}
current[lastKey] = value;
return updated;
};
// Usage
setPerson(updateNested(person, 'address.city', 'HCMC'));
// Helper: Toggle item trong array
const toggleItem = (array, id, key) =>
array.map((item) =>
item.id === id ? { ...item, [key]: !item[key] } : item
);
// Usage
setTodos(toggleItem(todos, 1, 'completed'));1.6. State Best Practices
1. Nhóm related state
jsx
// ❌ KHÔNG TỐT - Quá nhiều state riêng lẻ
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [address, setAddress] = useState('');
// ... nhiều state khác
}
// ✅ TỐT HƠN - Nhóm lại
function Form() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
phone: '',
address: '',
});
const updateField = (field, value) => {
setFormData({ ...formData, [field]: value });
};
}2. Tránh redundant state
jsx
// ❌ Redundant - fullName có thể tính từ firstName và lastName
function User() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // ❌ Không cần!
// Phải sync fullName mỗi khi firstName/lastName thay đổi
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
}
// ✅ Derived state - Tính toán trực tiếp
function User() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // ✅ Đơn giản hơn!
}3. Tránh duplicate state từ props
jsx
// ❌ SAI - Copy props vào state
function Message({ initialText }) {
const [text, setText] = useState(initialText);
// Khi initialText thay đổi, text không update!
return <div>{text}</div>;
}
// ✅ Option 1: Dùng props trực tiếp
function Message({ text }) {
return <div>{text}</div>;
}
// ✅ Option 2: Controlled component
function Message({ text, onChange }) {
return <input value={text} onChange={onChange} />;
}
// ✅ Option 3: Dùng key để reset
<Message key={userId} initialText={user.message} />;4. State structure tốt
jsx
// ❌ KHÔNG TỐT - Flat structure khó manage
const [users, setUsers] = useState([...]);
const [selectedUserId, setSelectedUserId] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [editingUserId, setEditingUserId] = useState(null);
// ✅ TÓT HƠN - Normalized structure
const [state, setState] = useState({
users: {
byId: {
'1': { id: '1', name: 'John' },
'2': { id: '2', name: 'Jane' }
},
allIds: ['1', '2']
},
ui: {
selectedId: null,
isEditing: false,
editingId: null
}
});💻 PHẦN 2: CODE DEMO (30-45 phút)
Demo 1: Form với Multiple State
jsx
function RegistrationForm() {
// Method 1: Multiple useState
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [agreeTerms, setAgreeTerms] = useState(false);
// Method 2: Single object state (tốt hơn)
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
agreeTerms: false,
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const updateField = (field) => (e) => {
const value =
e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setFormData((prev) => ({
...prev,
[field]: value,
}));
// Clear error khi user bắt đầu sửa
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: '' }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email là bắt buộc';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email không hợp lệ';
}
if (!formData.password) {
newErrors.password = 'Mật khẩu là bắt buộc';
} else if (formData.password.length < 6) {
newErrors.password = 'Mật khẩu phải ít nhất 6 ký tự';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Mật khẩu không khớp';
}
if (!formData.agreeTerms) {
newErrors.agreeTerms = 'Bạn phải đồng ý với điều khoản';
}
return newErrors;
};
const handleSubmit = async (e) => {
e.preventDefault();
const newErrors = validate();
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setIsSubmitting(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('Form submitted:', formData);
alert('Đăng ký thành công!');
// Reset form
setFormData({
email: '',
password: '',
confirmPassword: '',
agreeTerms: false,
});
} catch (error) {
setErrors({ submit: 'Có lỗi xảy ra. Vui lòng thử lại.' });
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type='email'
value={formData.email}
onChange={updateField('email')}
disabled={isSubmitting}
/>
{errors.email && <span className='error'>{errors.email}</span>}
</div>
<div>
<label>Mật khẩu:</label>
<input
type='password'
value={formData.password}
onChange={updateField('password')}
disabled={isSubmitting}
/>
{errors.password && (
<span className='error'>{errors.password}</span>
)}
</div>
<div>
<label>Xác nhận mật khẩu:</label>
<input
type='password'
value={formData.confirmPassword}
onChange={updateField('confirmPassword')}
disabled={isSubmitting}
/>
{errors.confirmPassword && (
<span className='error'>{errors.confirmPassword}</span>
)}
</div>
<div>
<label>
<input
type='checkbox'
checked={formData.agreeTerms}
onChange={updateField('agreeTerms')}
disabled={isSubmitting}
/>
Tôi đồng ý với điều khoản sử dụng
</label>
{errors.agreeTerms && (
<span className='error'>{errors.agreeTerms}</span>
)}
</div>
{errors.submit && <div className='error'>{errors.submit}</div>}
<button type='submit' disabled={isSubmitting}>
{isSubmitting ? 'Đang xử lý...' : 'Đăng ký'}
</button>
</form>
);
}Demo 2: Shopping Cart
jsx
function ShoppingCart() {
const [cart, setCart] = useState([]);
// Thêm sản phẩm vào giỏ
const addToCart = (product) => {
setCart((prevCart) => {
const existingItem = prevCart.find(
(item) => item.id === product.id
);
if (existingItem) {
// Tăng quantity nếu đã có
return prevCart.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
// Thêm mới
return [...prevCart, { ...product, quantity: 1 }];
});
};
// Xóa sản phẩm
const removeFromCart = (productId) => {
setCart((prevCart) => prevCart.filter((item) => item.id !== productId));
};
// Update quantity
const updateQuantity = (productId, newQuantity) => {
if (newQuantity < 1) {
removeFromCart(productId);
return;
}
setCart((prevCart) =>
prevCart.map((item) =>
item.id === productId
? { ...item, quantity: newQuantity }
: item
)
);
};
// Tính tổng
const total = cart.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
return (
<div>
<h2>Giỏ hàng ({itemCount} sản phẩm)</h2>
{cart.length === 0 ? (
<p>Giỏ hàng trống</p>
) : (
<>
<ul>
{cart.map((item) => (
<li key={item.id}>
<span>{item.name}</span>
<span>
{item.price.toLocaleString('vi-VN')}đ
</span>
<input
type='number'
value={item.quantity}
onChange={(e) =>
updateQuantity(
item.id,
parseInt(e.target.value) || 0
)
}
min='1'
/>
<button onClick={() => removeFromCart(item.id)}>
Xóa
</button>
</li>
))}
</ul>
<div>
<strong>
Tổng cộng: {total.toLocaleString('vi-VN')}đ
</strong>
</div>
<button onClick={() => setCart([])}>Xóa tất cả</button>
</>
)}
</div>
);
}🔨 PHẦN 3: THỰC HÀNH (60-90 phút)
Exercise 1: Counter Nâng Cao
jsx
function AdvancedCounter() {
// TODO:
// 1. Count state
// 2. Step size state (có thể thay đổi được)
// 3. History state (lưu các giá trị trước đó)
// 4. Min/max limits
// 5. Các nút: +, -, Reset, Undo, Redo
// 6. Hiển thị history
return <div>{/* Your code */}</div>;
}💡 Nhấn để xem lời giải
jsx
import { useState, type ChangeEvent } from 'react';
// TYPE DEFINITIONS
type Timeline = {
past: number[],
present: number,
future: number[],
};
type Limits = {
min: number,
max: number,
};
// CONSTANTS
const INITIAL_COUNT = 0;
const INITIAL_STEP = 1;
const DEFAULT_LIMITS: Limits = { min: -10, max: 10 };
// HELPER FUNCTIONS
/**
* Giới hạn giá trị trong khoảng min-max
* TẠI SAO: Đảm bảo counter luôn trong phạm vi cho phép
*/
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* Validate và parse step input từ user
* TẠI SAO: Ngăn step = 0 hoặc âm làm hỏng logic counter
* EDGE CASE: Trả về 1 nếu input không hợp lệ
*/
function parseStep(value: string): number {
const parsed = parseInt(value, 10);
return isNaN(parsed) || parsed <= 0 ? 1 : parsed;
}
// MAIN COMPONENT
function AdvancedCounter() {
// STATE: Timeline cho chức năng undo/redo
const [timeline, setTimeline] =
useState <
Timeline >
{
past: [],
present: INITIAL_COUNT,
future: [],
};
// STATE: Step size (tách riêng khỏi timeline - không cần undo)
const [step, setStep] = useState < number > INITIAL_STEP;
// STATE: Giới hạn min/max (tách riêng khỏi timeline)
const [limits, setLimits] = useState < Limits > DEFAULT_LIMITS;
// DERIVED STATE: Kiểm tra có thể undo/redo không
const canUndo = timeline.past.length > 0;
const canRedo = timeline.future.length > 0;
/**
* Cập nhật counter với giá trị mới
* TẠI SAO: Tập trung logic update timeline vào 1 chỗ
* QUY TẮC: Action mới sẽ xoá future stack (chuẩn undo/redo)
*/
const updateCount = (newValue: number): void => {
const clampedValue = clamp(newValue, limits.min, limits.max);
setTimeline((prev) => ({
past: [...prev.past, prev.present],
present: clampedValue,
future: [], // QUY TẮC: Action mới xoá redo stack
}));
};
// HANDLERS
const handleIncrement = (): void => {
updateCount(timeline.present + step);
};
const handleDecrement = (): void => {
updateCount(timeline.present - step);
};
/**
* Reset về giá trị ban đầu
* EDGE CASE: Reset được coi là action mới (có thể undo)
*/
const handleReset = (): void => {
updateCount(INITIAL_COUNT);
};
/**
* Undo: Chuyển present sang future, lấy giá trị cuối từ past
* EDGE CASE: Disabled khi past rỗng
* EDGE CASE: Phải clamp giá trị từ past nếu vượt limits hiện tại
*/
const handleUndo = (): void => {
if (!canUndo) return;
const previousValue = timeline.past[timeline.past.length - 1];
const clampedValue = clamp(previousValue, limits.min, limits.max);
setTimeline((prev) => ({
past: prev.past.slice(0, -1),
present: clampedValue,
future: [prev.present, ...prev.future],
}));
};
/**
* Redo: Chuyển present sang past, lấy giá trị đầu từ future
* EDGE CASE: Disabled khi future rỗng
* EDGE CASE: Phải clamp giá trị từ future nếu vượt limits hiện tại
*/
const handleRedo = (): void => {
if (!canRedo) return;
const nextValue = timeline.future[0];
const clampedValue = clamp(nextValue, limits.min, limits.max);
setTimeline((prev) => ({
past: [...prev.past, prev.present],
present: clampedValue,
future: prev.future.slice(1),
}));
};
const handleStepChange = (e: ChangeEvent<HTMLInputElement>): void => {
setStep(parseStep(e.target.value));
};
const handleMinChange = (e: ChangeEvent<HTMLInputElement>): void => {
const newMin = parseInt(e.target.value, 10);
if (!isNaN(newMin)) {
setLimits((prev) => ({ ...prev, min: newMin }));
// EDGE CASE: Re-clamp nếu giá trị hiện tại vượt giới hạn mới
const clamped = clamp(timeline.present, newMin, limits.max);
if (clamped !== timeline.present) {
updateCount(clamped);
}
}
};
const handleMaxChange = (e: ChangeEvent<HTMLInputElement>): void => {
const newMax = parseInt(e.target.value, 10);
if (!isNaN(newMax)) {
setLimits((prev) => ({ ...prev, max: newMax }));
// EDGE CASE: Re-clamp nếu giá trị hiện tại vượt giới hạn mới
const clamped = clamp(timeline.present, limits.min, newMax);
if (clamped !== timeline.present) {
updateCount(clamped);
}
}
};
return (
<div className='counter'>
<div className='counter__container'>
{/* HEADER */}
<h1 className='counter__title'>Advanced Counter</h1>
{/* HIỂN THỊ GIÁ TRỊ HIỆN TẠI */}
<div className='counter__display'>
<div className='counter__display-label'>Current Count</div>
<div className='counter__display-value'>
{timeline.present}
</div>
</div>
{/* CONTROLS */}
<div className='counter__controls'>
{/* INCREMENT / DECREMENT */}
<div className='counter__actions'>
<button
onClick={handleDecrement}
className='counter__button counter__button--decrement'
disabled={timeline.present <= limits.min}
>
- {step}
</button>
<button
onClick={handleIncrement}
className='counter__button counter__button--increment'
disabled={timeline.present >= limits.max}
>
+ {step}
</button>
</div>
{/* UNDO / REDO / RESET */}
<div className='counter__actions'>
<button
onClick={handleUndo}
disabled={!canUndo}
className='counter__button counter__button--undo'
>
↶ Undo
</button>
<button
onClick={handleReset}
className='counter__button counter__button--reset'
>
Reset
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
className='counter__button counter__button--redo'
>
↷ Redo
</button>
</div>
</div>
{/* SETTINGS */}
<div className='counter__settings'>
<div className='counter__setting'>
<label className='counter__setting-label'>Step:</label>
<input
type='number'
value={step}
onChange={handleStepChange}
min='1'
className='counter__setting-input'
/>
</div>
<div className='counter__setting'>
<label className='counter__setting-label'>Min:</label>
<input
type='number'
value={limits.min}
onChange={handleMinChange}
className='counter__setting-input'
/>
</div>
<div className='counter__setting'>
<label className='counter__setting-label'>Max:</label>
<input
type='number'
value={limits.max}
onChange={handleMaxChange}
className='counter__setting-input'
/>
</div>
</div>
{/* HIỂN THỊ HISTORY */}
<div className='counter__history'>
<h3 className='counter__history-title'>History</h3>
<div className='counter__history-list'>
{timeline.past.length === 0 && (
<span className='counter__history-empty'>
No history yet
</span>
)}
{timeline.past.map((value, index) => (
<span key={index} className='counter__history-item'>
{value}
</span>
))}
{/* Giá trị hiện tại được highlight */}
<span className='counter__history-item counter__history-item--current'>
{timeline.present}
</span>
</div>
</div>
{/* INFO */}
<div className='counter__info'>
<p className='counter__info-text'>
Range: {limits.min} to {limits.max}
</p>
<p className='counter__info-text'>
History: {timeline.past.length} | Future:{' '}
{timeline.future.length}
</p>
</div>
</div>
</div>
);
}
export default AdvancedCounter;Unit Test – AdvancedCounter.test.tsx
ts
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import AdvancedCounter from './AdvancedCounter';
// Helper function để lấy giá trị counter chính (không phải trong history)
const getCounterValue = (): HTMLElement => {
return screen.getByText('Current Count').nextElementSibling as HTMLElement;
};
describe('AdvancedCounter', () => {
describe('Basic increment/decrement', () => {
test('should start at 0', () => {
render(<AdvancedCounter />);
expect(getCounterValue()).toHaveTextContent('0');
});
test('should increment by step size', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
fireEvent.click(incrementBtn);
expect(getCounterValue()).toHaveTextContent('1');
fireEvent.click(incrementBtn);
expect(getCounterValue()).toHaveTextContent('2');
});
test('should decrement by step size', () => {
render(<AdvancedCounter />);
const decrementBtn = screen.getByText('- 1');
fireEvent.click(decrementBtn);
expect(getCounterValue()).toHaveTextContent('-1');
});
});
describe('Step size modification', () => {
test('should change step size and increment accordingly', () => {
render(<AdvancedCounter />);
const stepInput = screen.getByDisplayValue('1');
// Thay đổi step thành 5
fireEvent.change(stepInput, { target: { value: '5' } });
// Nút increment bây giờ phải hiển thị +5
expect(screen.getByText('+ 5')).toBeInTheDocument();
// Click sẽ cộng 5
const incrementBtn = screen.getByText('+ 5');
fireEvent.click(incrementBtn);
expect(getCounterValue()).toHaveTextContent('5');
});
test('should handle invalid step input by defaulting to 1', () => {
render(<AdvancedCounter />);
const stepInput = screen.getByDisplayValue('1');
// Thử input không hợp lệ: 0
fireEvent.change(stepInput, { target: { value: '0' } });
expect(screen.getByText('+ 1')).toBeInTheDocument();
// Thử input không hợp lệ: số âm
fireEvent.change(stepInput, { target: { value: '-5' } });
expect(screen.getByText('+ 1')).toBeInTheDocument();
});
test('should handle non-numeric step input', () => {
render(<AdvancedCounter />);
const stepInput = screen.getByDisplayValue('1');
// Thử input chữ
fireEvent.change(stepInput, { target: { value: 'abc' } });
expect(screen.getByText('+ 1')).toBeInTheDocument();
});
});
describe('Min/Max limits', () => {
test('should clamp value at max limit', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
// Click 15 lần (max là 10)
for (let i = 0; i < 15; i++) {
fireEvent.click(incrementBtn);
}
// Phải dừng ở 10
expect(getCounterValue()).toHaveTextContent('10');
});
test('should clamp value at min limit', () => {
render(<AdvancedCounter />);
const decrementBtn = screen.getByText('- 1');
// Click 15 lần (min là -10)
for (let i = 0; i < 15; i++) {
fireEvent.click(decrementBtn);
}
// Phải dừng ở -10
expect(getCounterValue()).toHaveTextContent('-10');
});
test('should disable increment button at max', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
// Đi đến max
for (let i = 0; i < 10; i++) {
fireEvent.click(incrementBtn);
}
expect(incrementBtn).toBeDisabled();
});
test('should disable decrement button at min', () => {
render(<AdvancedCounter />);
const decrementBtn = screen.getByText('- 1');
// Đi đến min
for (let i = 0; i < 10; i++) {
fireEvent.click(decrementBtn);
}
expect(decrementBtn).toBeDisabled();
});
test('should enable increment button when below max', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
// Đi đến max
for (let i = 0; i < 10; i++) {
fireEvent.click(incrementBtn);
}
expect(incrementBtn).toBeDisabled();
// Undo về 9
const undoBtn = screen.getByText('↶ Undo');
fireEvent.click(undoBtn);
// Button phải enable lại
expect(incrementBtn).not.toBeDisabled();
});
test('should enable decrement button when above min', () => {
render(<AdvancedCounter />);
const decrementBtn = screen.getByText('- 1');
// Đi đến min
for (let i = 0; i < 10; i++) {
fireEvent.click(decrementBtn);
}
expect(decrementBtn).toBeDisabled();
// Undo về -9
const undoBtn = screen.getByText('↶ Undo');
fireEvent.click(undoBtn);
// Button phải enable lại
expect(decrementBtn).not.toBeDisabled();
});
});
describe('Undo/Redo functionality', () => {
test('should undo to previous value', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(incrementBtn); // 1 -> 2
expect(getCounterValue()).toHaveTextContent('2');
fireEvent.click(undoBtn); // 2 -> 1
expect(getCounterValue()).toHaveTextContent('1');
});
test('should undo multiple times', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(incrementBtn); // 1 -> 2
fireEvent.click(incrementBtn); // 2 -> 3
fireEvent.click(undoBtn); // 3 -> 2
fireEvent.click(undoBtn); // 2 -> 1
fireEvent.click(undoBtn); // 1 -> 0
expect(getCounterValue()).toHaveTextContent('0');
});
test('should redo after undo', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
const redoBtn = screen.getByText('↷ Redo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(undoBtn); // 1 -> 0
fireEvent.click(redoBtn); // 0 -> 1
expect(getCounterValue()).toHaveTextContent('1');
});
test('should redo multiple times', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
const redoBtn = screen.getByText('↷ Redo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(incrementBtn); // 1 -> 2
fireEvent.click(incrementBtn); // 2 -> 3
fireEvent.click(undoBtn); // 3 -> 2
fireEvent.click(undoBtn); // 2 -> 1
fireEvent.click(redoBtn); // 1 -> 2
fireEvent.click(redoBtn); // 2 -> 3
expect(getCounterValue()).toHaveTextContent('3');
});
test('should disable undo when no history', () => {
render(<AdvancedCounter />);
const undoBtn = screen.getByText('↶ Undo');
expect(undoBtn).toBeDisabled();
});
test('should enable undo after first action', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
expect(undoBtn).toBeDisabled();
fireEvent.click(incrementBtn);
expect(undoBtn).not.toBeDisabled();
});
test('should disable redo when no future', () => {
render(<AdvancedCounter />);
const redoBtn = screen.getByText('↷ Redo');
expect(redoBtn).toBeDisabled();
});
test('should enable redo after undo', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
const redoBtn = screen.getByText('↷ Redo');
fireEvent.click(incrementBtn);
expect(redoBtn).toBeDisabled();
fireEvent.click(undoBtn);
expect(redoBtn).not.toBeDisabled();
});
test('should clear redo stack on new action', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const decrementBtn = screen.getByText('- 1');
const undoBtn = screen.getByText('↶ Undo');
const redoBtn = screen.getByText('↷ Redo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(incrementBtn); // 1 -> 2
fireEvent.click(undoBtn); // 2 -> 1 (redo available)
expect(redoBtn).not.toBeDisabled();
fireEvent.click(decrementBtn); // 1 -> 0 (phải clear redo)
expect(redoBtn).toBeDisabled();
});
test('should maintain correct undo/redo with mixed operations', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const decrementBtn = screen.getByText('- 1');
const undoBtn = screen.getByText('↶ Undo');
const redoBtn = screen.getByText('↷ Redo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(incrementBtn); // 1 -> 2
fireEvent.click(decrementBtn); // 2 -> 1
fireEvent.click(undoBtn); // 1 -> 2
expect(getCounterValue()).toHaveTextContent('2');
fireEvent.click(undoBtn); // 2 -> 1
expect(getCounterValue()).toHaveTextContent('1');
fireEvent.click(redoBtn); // 1 -> 2
expect(getCounterValue()).toHaveTextContent('2');
});
});
describe('Reset functionality', () => {
test('should reset to initial value (0)', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const resetBtn = screen.getByText('Reset');
fireEvent.click(incrementBtn);
fireEvent.click(incrementBtn);
expect(getCounterValue()).toHaveTextContent('2');
fireEvent.click(resetBtn);
expect(getCounterValue()).toHaveTextContent('0');
});
test('should reset from negative values', () => {
render(<AdvancedCounter />);
const decrementBtn = screen.getByText('- 1');
const resetBtn = screen.getByText('Reset');
fireEvent.click(decrementBtn);
fireEvent.click(decrementBtn);
expect(getCounterValue()).toHaveTextContent('-2');
fireEvent.click(resetBtn);
expect(getCounterValue()).toHaveTextContent('0');
});
test('should allow undo after reset', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const resetBtn = screen.getByText('Reset');
const undoBtn = screen.getByText('↶ Undo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(resetBtn); // 1 -> 0
fireEvent.click(undoBtn); // 0 -> 1
expect(getCounterValue()).toHaveTextContent('1');
});
test('should clear redo stack after reset', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const resetBtn = screen.getByText('Reset');
const undoBtn = screen.getByText('↶ Undo');
const redoBtn = screen.getByText('↷ Redo');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(undoBtn); // 1 -> 0
expect(redoBtn).not.toBeDisabled();
fireEvent.click(resetBtn); // Reset (clears redo)
expect(redoBtn).toBeDisabled();
});
});
describe('History display', () => {
test('should show "No history yet" initially', () => {
render(<AdvancedCounter />);
expect(screen.getByText('No history yet')).toBeInTheDocument();
});
test('should display past values in history', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(incrementBtn); // 1 -> 2
// History phải hiển thị: 0, 1, [2 là current]
const historySection = screen.getByText('History').parentElement;
expect(historySection).toHaveTextContent('0');
expect(historySection).toHaveTextContent('1');
});
test('should hide "No history yet" after first action', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
expect(screen.getByText('No history yet')).toBeInTheDocument();
fireEvent.click(incrementBtn);
expect(
screen.queryByText('No history yet')
).not.toBeInTheDocument();
});
test('should update history on each action', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const decrementBtn = screen.getByText('- 1');
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.click(incrementBtn); // 1 -> 2
fireEvent.click(decrementBtn); // 2 -> 1
const historySection = screen.getByText('History').parentElement;
expect(historySection).toHaveTextContent('0');
expect(historySection).toHaveTextContent('2');
});
});
describe('Edge cases with limit changes', () => {
test('should re-clamp when max is reduced below current value', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const maxInput = screen.getByDisplayValue('10');
// Đi đến 5
for (let i = 0; i < 5; i++) {
fireEvent.click(incrementBtn);
}
expect(getCounterValue()).toHaveTextContent('5');
// Đổi max thành 3 (phải clamp 5 -> 3)
fireEvent.change(maxInput, { target: { value: '3' } });
expect(getCounterValue()).toHaveTextContent('3');
});
test('should re-clamp when min is increased above current value', () => {
render(<AdvancedCounter />);
const decrementBtn = screen.getByText('- 1');
const minInput = screen.getByDisplayValue('-10');
// Đi đến -5
for (let i = 0; i < 5; i++) {
fireEvent.click(decrementBtn);
}
expect(getCounterValue()).toHaveTextContent('-5');
// Đổi min thành -3 (phải clamp -5 -> -3)
fireEvent.change(minInput, { target: { value: '-3' } });
expect(getCounterValue()).toHaveTextContent('-3');
});
test('should not change value when limits still contain current value', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const maxInput = screen.getByDisplayValue('10');
// Đi đến 5
for (let i = 0; i < 5; i++) {
fireEvent.click(incrementBtn);
}
expect(getCounterValue()).toHaveTextContent('5');
// Đổi max thành 8 (5 vẫn trong range, không đổi)
fireEvent.change(maxInput, { target: { value: '8' } });
expect(getCounterValue()).toHaveTextContent('5');
});
test('should handle negative min/max values', () => {
render(<AdvancedCounter />);
const minInput = screen.getByDisplayValue('-10');
const maxInput = screen.getByDisplayValue('10');
// Set range từ -50 đến -10
fireEvent.change(minInput, { target: { value: '-50' } });
fireEvent.change(maxInput, { target: { value: '-10' } });
// Current value (0) phải clamp về -10
expect(getCounterValue()).toHaveTextContent('-10');
});
});
describe('Info display', () => {
test('should display correct range', () => {
render(<AdvancedCounter />);
expect(screen.getByText('Range: -10 to 10')).toBeInTheDocument();
});
test('should update range display when limits change', () => {
render(<AdvancedCounter />);
const minInput = screen.getByDisplayValue('-10');
const maxInput = screen.getByDisplayValue('10');
fireEvent.change(minInput, { target: { value: '-20' } });
fireEvent.change(maxInput, { target: { value: '30' } });
expect(screen.getByText('Range: -20 to 30')).toBeInTheDocument();
});
test('should display correct history and future counts', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
// Ban đầu: History: 0 | Future: 0
expect(
screen.getByText(/History: 0 \| Future: 0/)
).toBeInTheDocument();
fireEvent.click(incrementBtn);
fireEvent.click(incrementBtn);
// History: 2 | Future: 0
expect(
screen.getByText(/History: 2 \| Future: 0/)
).toBeInTheDocument();
fireEvent.click(undoBtn);
// History: 1 | Future: 1
expect(
screen.getByText(/History: 1 \| Future: 1/)
).toBeInTheDocument();
});
});
describe('Integration tests', () => {
test('should handle complex workflow correctly', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const undoBtn = screen.getByText('↶ Undo');
const redoBtn = screen.getByText('↷ Redo');
const resetBtn = screen.getByText('Reset');
const stepInput = screen.getByDisplayValue('1');
// Workflow: increment -> change step -> increment -> undo -> redo -> reset
fireEvent.click(incrementBtn); // 0 -> 1
fireEvent.change(stepInput, { target: { value: '3' } });
const newIncrementBtn = screen.getByText('+ 3');
fireEvent.click(newIncrementBtn); // 1 -> 4
expect(getCounterValue()).toHaveTextContent('4');
fireEvent.click(undoBtn); // 4 -> 1
expect(getCounterValue()).toHaveTextContent('1');
fireEvent.click(redoBtn); // 1 -> 4
expect(getCounterValue()).toHaveTextContent('4');
fireEvent.click(resetBtn); // 4 -> 0
expect(getCounterValue()).toHaveTextContent('0');
});
test('should handle edge case workflow with limits', () => {
render(<AdvancedCounter />);
const incrementBtn = screen.getByText('+ 1');
const maxInput = screen.getByDisplayValue('10');
const undoBtn = screen.getByText('↶ Undo');
// Go to 8
for (let i = 0; i < 8; i++) {
fireEvent.click(incrementBtn);
}
expect(getCounterValue()).toHaveTextContent('8');
// Reduce max to 5
fireEvent.change(maxInput, { target: { value: '5' } });
// Counter phải clamp về 5
expect(getCounterValue()).toHaveTextContent('5');
// Bây giờ thử decrement (phải hoạt động bình thường)
const decrementBtn = screen.getByText('- 1');
fireEvent.click(decrementBtn);
expect(getCounterValue()).toHaveTextContent('4');
// Undo về 5
fireEvent.click(undoBtn);
expect(getCounterValue()).toHaveTextContent('5');
// Undo lại lần nữa - về 8 nhưng phải clamp về 5 (vì max = 5)
fireEvent.click(undoBtn);
expect(getCounterValue()).toHaveTextContent('5');
});
});
});Exercise 2: Todo App Hoàn Chỉnh
jsx
function TodoApp() {
// TODO:
// 1. Todos array state với: id, text, completed, priority, createdAt
// 2. Input state
// 3. Filter state (all/active/completed)
// 4. Sort state (date/priority/alphabetical)
// 5. Chức năng:
// - Thêm todo
// - Xóa todo
// - Toggle completed
// - Edit todo (inline editing)
// - Set priority (low/medium/high)
// - Filter và sort
// - Clear completed
// - Toggle all
// 6. Save vào localStorage
// 7. Stats: total, active, completed
return <div>{/* Your code */}</div>;
}💡 Nhấn để xem lời giải
jsx
import React, { useCallback, useEffect, useMemo, useState } from 'react';
// ============================================
// TYPES
// ============================================
type Priority = 'low' | 'medium' | 'high';
type FilterType = 'all' | 'active' | 'completed';
type SortType = 'date' | 'priority' | 'alphabetical';
type Todo = {
id: string;
text: string;
completed: boolean;
priority: Priority;
createdAt: number;
};
type Filters = {
filter: FilterType;
sort: SortType;
};
type Stats = {
total: number;
active: number;
completed: number;
};
// ============================================
// CONSTANTS
// ============================================
const FILTER_OPTIONS: { value: FilterType; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'completed', label: 'Completed' },
];
const SORT_OPTIONS: { value: SortType; label: string }[] = [
{ value: 'date', label: 'Date' },
{ value: 'priority', label: 'Priority' },
{ value: 'alphabetical', label: 'A-Z' },
];
const PRIORITY_OPTIONS: { value: Priority; label: string; color: string }[] = [
{ value: 'low', label: 'Low', color: '#22c55e' },
{ value: 'medium', label: 'Medium', color: '#eab308' },
{ value: 'high', label: 'High', color: '#ef4444' },
];
const STORAGE_KEY = 'todos-app-data';
const MAX_TODO_LENGTH = 200;
// ============================================
// HELPERS
// ============================================
function createTodo(text: string): Todo {
return {
id: crypto.randomUUID(),
text: text.trim(),
completed: false,
priority: 'low',
createdAt: Date.now(),
};
}
function loadFromLocalStorage(): Todo[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Failed to load from localStorage:', error);
return [];
}
}
function syncLocalStorage(todos: Todo[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
}
function filterTodos(todos: Todo[], filterType: FilterType): Todo[] {
switch (filterType) {
case 'active':
return todos.filter((todo) => !todo.completed);
case 'completed':
return todos.filter((todo) => todo.completed);
default:
return todos;
}
}
function sortTodos(todos: Todo[], sortType: SortType): Todo[] {
const sorted = [...todos];
switch (sortType) {
case 'date':
return sorted.sort((a, b) => b.createdAt - a.createdAt);
case 'priority': {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return sorted.sort(
(a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]
);
}
case 'alphabetical':
return sorted.sort((a, b) => a.text.localeCompare(b.text));
default:
return sorted;
}
}
function calculateStats(todos: Todo[]): Stats {
return {
total: todos.length,
active: todos.filter((t) => !t.completed).length,
completed: todos.filter((t) => t.completed).length,
};
}
// ============================================
// COMPONENTS
// ============================================
function TodoFilters({
filters,
onFilterChange,
}: {
filters: Filters;
onFilterChange: (filters: Filters) => void;
}) {
return (
<div className='filters'>
<div className='filter-group'>
<label htmlFor='filter-select'>Show:</label>
<select
id='filter-select'
value={filters.filter}
onChange={(e) =>
onFilterChange({
...filters,
filter: e.target.value as FilterType,
})
}
className='filter-select'
>
{FILTER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className='filter-group'>
<label htmlFor='sort-select'>Sort by:</label>
<select
id='sort-select'
value={filters.sort}
onChange={(e) =>
onFilterChange({
...filters,
sort: e.target.value as SortType,
})
}
className='filter-select'
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
);
}
function TodoItem({
todo,
onToggle,
onDelete,
onEdit,
onUpdatePriority,
}: {
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string, text: string) => void;
onUpdatePriority: (id: string, priority: Priority) => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const handleSaveEdit = useCallback(() => {
const trimmed = editText.trim();
if (trimmed && trimmed !== todo.text) {
onEdit(todo.id, trimmed);
} else {
setEditText(todo.text);
}
setIsEditing(false);
}, [editText, todo.id, todo.text, onEdit]);
const handleCancelEdit = useCallback(() => {
setEditText(todo.text);
setIsEditing(false);
}, [todo.text]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSaveEdit();
} else if (e.key === 'Escape') {
handleCancelEdit();
}
},
[handleSaveEdit, handleCancelEdit]
);
return (
<li
className={`todo-item ${
todo.completed ? 'todo-item--completed' : ''
}`}
>
<div className='todo-item__main'>
<input
type='checkbox'
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className='todo-item__checkbox'
aria-label={`Mark "${todo.text}" as ${
todo.completed ? 'incomplete' : 'complete'
}`}
/>
{isEditing ? (
<input
type='text'
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSaveEdit}
className='todo-item__edit-input'
autoFocus
maxLength={MAX_TODO_LENGTH}
/>
) : (
<span className='todo-item__text'>{todo.text}</span>
)}
</div>
<div className='todo-item__actions'>
{!isEditing && (
<>
<select
value={todo.priority}
onChange={(e) =>
onUpdatePriority(
todo.id,
e.target.value as Priority
)
}
className='todo-item__priority'
style={{
borderColor: PRIORITY_OPTIONS.find(
(p) => p.value === todo.priority
)?.color,
}}
aria-label='Priority'
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
onClick={() => setIsEditing(true)}
className='todo-item__btn todo-item__btn--edit'
aria-label='Edit todo'
>
✏️
</button>
<button
onClick={() => onDelete(todo.id)}
className='todo-item__btn todo-item__btn--delete'
aria-label='Delete todo'
>
🗑️
</button>
</>
)}
{isEditing && (
<>
<button
onClick={handleSaveEdit}
className='todo-item__btn todo-item__btn--save'
aria-label='Save'
>
✓
</button>
<button
onClick={handleCancelEdit}
className='todo-item__btn todo-item__btn--cancel'
aria-label='Cancel'
>
✕
</button>
</>
)}
</div>
</li>
);
}
function TodoList({
todos,
onToggle,
onDelete,
onEdit,
onUpdatePriority,
}: {
todos: Todo[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
onEdit: (id: string, text: string) => void;
onUpdatePriority: (id: string, priority: Priority) => void;
}) {
if (todos.length === 0) {
return (
<p className='todo-list__empty'>No todos yet. Add one above! 🎯</p>
);
}
return (
<ul className='todo-list'>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
onEdit={onEdit}
onUpdatePriority={onUpdatePriority}
/>
))}
</ul>
);
}
function TodoStats({ stats }: { stats: Stats }) {
return (
<div className='todo-stats'>
<div className='todo-stats__item'>
<span className='todo-stats__label'>Total:</span>
<span className='todo-stats__value'>{stats.total}</span>
</div>
<div className='todo-stats__item'>
<span className='todo-stats__label'>Active:</span>
<span className='todo-stats__value todo-stats__value--active'>
{stats.active}
</span>
</div>
<div className='todo-stats__item'>
<span className='todo-stats__label'>Completed:</span>
<span className='todo-stats__value todo-stats__value--completed'>
{stats.completed}
</span>
</div>
</div>
);
}
// ============================================
// MAIN APP
// ============================================
export default function TodoApp() {
const [todos, setTodos] = useState<Todo[]>(() => loadFromLocalStorage());
const [inputValue, setInputValue] = useState('');
const [filters, setFilters] = useState<Filters>({
filter: 'all',
sort: 'date',
});
const [error, setError] = useState('');
// Sync to localStorage whenever todos change
useEffect(() => {
syncLocalStorage(todos);
}, [todos]);
// DERIVED STATE: Filtered and sorted todos
const filteredTodos = useMemo(() => {
const filtered = filterTodos(todos, filters.filter);
return sortTodos(filtered, filters.sort);
}, [todos, filters]);
// DERIVED STATE: Stats
const stats = useMemo(() => calculateStats(todos), [todos]);
// ============================================
// HANDLERS
// ============================================
const handleAddTodo = useCallback(() => {
const trimmed = inputValue.trim();
if (!trimmed) {
setError('Please enter a todo');
return;
}
if (trimmed.length > MAX_TODO_LENGTH) {
setError(`Todo must be less than ${MAX_TODO_LENGTH} characters`);
return;
}
const newTodo = createTodo(trimmed);
setTodos((prev) => [newTodo, ...prev]);
setInputValue('');
setError('');
}, [inputValue]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleAddTodo();
}
},
[handleAddTodo]
);
const handleToggle = useCallback((id: string) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
const handleDelete = useCallback((id: string) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
}, []);
const handleEdit = useCallback((id: string, text: string) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, text } : todo))
);
}, []);
const handleUpdatePriority = useCallback(
(id: string, priority: Priority) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, priority } : todo
)
);
},
[]
);
const handleToggleAll = useCallback(() => {
const hasActive = todos.some((todo) => !todo.completed);
setTodos((prev) =>
prev.map((todo) => ({ ...todo, completed: hasActive }))
);
}, [todos]);
const handleClearCompleted = useCallback(() => {
setTodos((prev) => prev.filter((todo) => !todo.completed));
}, []);
return (
<div className='app'>
<div className='container'>
<header className='app__header'>
<h1>📝 Advanced Todo App</h1>
<p className='app__subtitle'>
Production-ready with full features
</p>
</header>
{/* INPUT SECTION */}
<div className='input-section'>
<div className='input-wrapper'>
<input
type='text'
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
if (error) setError('');
}}
onKeyDown={handleKeyDown}
placeholder='What needs to be done?'
className='todo-input'
maxLength={MAX_TODO_LENGTH}
aria-label='New todo'
aria-invalid={!!error}
aria-describedby={error ? 'input-error' : undefined}
/>
<button
onClick={handleAddTodo}
className='btn btn--add'
aria-label='Add todo'
>
Add
</button>
</div>
{error && (
<p
id='input-error'
className='input-error'
role='alert'
>
{error}
</p>
)}
</div>
{/* ACTIONS */}
<div className='actions'>
<button
onClick={handleToggleAll}
disabled={todos.length === 0}
className='btn btn--secondary'
>
Toggle All
</button>
<button
onClick={handleClearCompleted}
disabled={stats.completed === 0}
className='btn btn--secondary'
>
Clear Completed ({stats.completed})
</button>
</div>
{/* FILTERS */}
<TodoFilters filters={filters} onFilterChange={setFilters} />
{/* STATS */}
<TodoStats stats={stats} />
{/* TODO LIST */}
<TodoList
todos={filteredTodos}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={handleEdit}
onUpdatePriority={handleUpdatePriority}
/>
</div>
</div>
);
}Unit Test – TodoApp.test.tsx
ts
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoApp from './TodoApp';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Mock crypto.randomUUID
const mockUUID = jest.fn();
let uuidCounter = 0;
Object.defineProperty(globalThis, 'crypto', {
value: {
randomUUID: () => {
const id = `test-uuid-${uuidCounter++}`;
mockUUID();
return id;
},
},
});
describe('TodoApp', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
uuidCounter = 0;
mockUUID.mockClear();
});
// ============================================
// HELPER FUNCTIONS
// ============================================
const addTodo = async (text: string) => {
const input = screen.getByPlaceholderText(/what needs to be done/i);
const addButton = screen.getByRole('button', { name: /add todo/i });
await userEvent.type(input, text);
fireEvent.click(addButton);
};
const getTodoCheckbox = (text: string) => {
const todoText = screen.getByText(text);
const todoItem = todoText.closest('.todo-item');
return todoItem?.querySelector(
'input[type="checkbox"]'
) as HTMLInputElement;
};
const getDeleteButton = (text: string) => {
const todoText = screen.getByText(text);
const todoItem = todoText.closest('.todo-item');
return todoItem?.querySelector(
'[aria-label="Delete todo"]'
) as HTMLButtonElement;
};
const getEditButton = (text: string) => {
const todoText = screen.getByText(text);
const todoItem = todoText.closest('.todo-item');
return todoItem?.querySelector(
'[aria-label="Edit todo"]'
) as HTMLButtonElement;
};
// ============================================
// TEST 1: ADD TODO
// ============================================
describe('Add Todo', () => {
test('should add a new todo', async () => {
render(<TodoApp />);
await addTodo('Buy groceries');
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.getByText(/total:/i).nextSibling?.textContent).toBe(
'1'
);
});
test('should clear input after adding todo', async () => {
render(<TodoApp />);
const input = screen.getByPlaceholderText(/what needs to be done/i);
await addTodo('Test todo');
expect(input).toHaveValue('');
});
test('should not add empty todo', async () => {
render(<TodoApp />);
const addButton = screen.getByRole('button', { name: /add todo/i });
fireEvent.click(addButton);
expect(
screen.getByText(/please enter a todo/i)
).toBeInTheDocument();
expect(
screen.queryByText(/total:/i)?.nextSibling?.textContent
).toBe('0');
});
test('should add todo on Enter key', async () => {
render(<TodoApp />);
const input = screen.getByPlaceholderText(/what needs to be done/i);
await userEvent.type(input, 'Test Enter key{enter}');
expect(screen.getByText('Test Enter key')).toBeInTheDocument();
});
test('should trim whitespace from todo text', async () => {
render(<TodoApp />);
await addTodo(' Whitespace test ');
expect(screen.getByText('Whitespace test')).toBeInTheDocument();
});
});
// ============================================
// TEST 2: DELETE TODO
// ============================================
describe('Delete Todo', () => {
test('should delete a todo', async () => {
render(<TodoApp />);
await addTodo('Todo to delete');
const deleteButton = getDeleteButton('Todo to delete');
fireEvent.click(deleteButton);
expect(
screen.queryByText('Todo to delete')
).not.toBeInTheDocument();
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});
test('should delete correct todo from multiple todos', async () => {
render(<TodoApp />);
await addTodo('Todo 1');
await addTodo('Todo 2');
await addTodo('Todo 3');
const deleteButton = getDeleteButton('Todo 2');
fireEvent.click(deleteButton);
expect(screen.queryByText('Todo 2')).not.toBeInTheDocument();
expect(screen.getByText('Todo 1')).toBeInTheDocument();
expect(screen.getByText('Todo 3')).toBeInTheDocument();
});
});
// ============================================
// TEST 3: TOGGLE TODO
// ============================================
describe('Toggle Todo', () => {
test('should toggle todo completion', async () => {
render(<TodoApp />);
await addTodo('Todo to toggle');
const checkbox = getTodoCheckbox('Todo to toggle');
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
expect(checkbox).toBeChecked();
fireEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
});
test('should update stats when toggling', async () => {
render(<TodoApp />);
await addTodo('Test todo');
const checkbox = getTodoCheckbox('Test todo');
// Initially: 1 active, 0 completed
expect(screen.getByText(/active:/i).nextSibling?.textContent).toBe(
'1'
);
expect(
screen.getByText(/completed:/i).nextSibling?.textContent
).toBe('0');
// After toggling: 0 active, 1 completed
fireEvent.click(checkbox);
expect(screen.getByText(/active:/i).nextSibling?.textContent).toBe(
'0'
);
expect(
screen.getByText(/completed:/i).nextSibling?.textContent
).toBe('1');
});
test('should apply completed styles', async () => {
render(<TodoApp />);
await addTodo('Style test');
const checkbox = getTodoCheckbox('Style test');
const todoItem = checkbox.closest('.todo-item');
expect(todoItem).not.toHaveClass('todo-item--completed');
fireEvent.click(checkbox);
expect(todoItem).toHaveClass('todo-item--completed');
});
});
// ============================================
// TEST 4: LOCALSTORAGE SYNC
// ============================================
describe('LocalStorage Sync', () => {
test('should save todos to localStorage', async () => {
render(<TodoApp />);
await addTodo('Persist this');
const stored = localStorage.getItem('todos-app-data');
expect(stored).toBeTruthy();
const parsed = JSON.parse(stored!);
expect(parsed).toHaveLength(1);
expect(parsed[0].text).toBe('Persist this');
});
test('should load todos from localStorage on mount', () => {
// Pre-populate localStorage
const mockTodos = [
{
id: 'stored-1',
text: 'Stored todo',
completed: false,
priority: 'low',
createdAt: Date.now(),
},
];
localStorage.setItem('todos-app-data', JSON.stringify(mockTodos));
render(<TodoApp />);
expect(screen.getByText('Stored todo')).toBeInTheDocument();
});
test('should update localStorage when deleting', async () => {
render(<TodoApp />);
await addTodo('Delete me');
const deleteButton = getDeleteButton('Delete me');
fireEvent.click(deleteButton);
const stored = localStorage.getItem('todos-app-data');
const parsed = JSON.parse(stored!);
expect(parsed).toHaveLength(0);
});
test('should update localStorage when toggling', async () => {
render(<TodoApp />);
await addTodo('Toggle me');
const checkbox = getTodoCheckbox('Toggle me');
fireEvent.click(checkbox);
const stored = localStorage.getItem('todos-app-data');
const parsed = JSON.parse(stored!);
expect(parsed[0].completed).toBe(true);
});
});
// ============================================
// TEST 5: FILTER
// ============================================
describe('Filters', () => {
beforeEach(async () => {
render(<TodoApp />);
await addTodo('Active todo');
await addTodo('Completed todo');
const checkbox = getTodoCheckbox('Completed todo');
fireEvent.click(checkbox);
});
test('should filter active todos', () => {
const filterSelect = screen.getByLabelText(/show:/i);
fireEvent.change(filterSelect, { target: { value: 'active' } });
expect(screen.getByText('Active todo')).toBeInTheDocument();
expect(
screen.queryByText('Completed todo')
).not.toBeInTheDocument();
});
test('should filter completed todos', () => {
const filterSelect = screen.getByLabelText(/show:/i);
fireEvent.change(filterSelect, { target: { value: 'completed' } });
expect(screen.queryByText('Active todo')).not.toBeInTheDocument();
expect(screen.getByText('Completed todo')).toBeInTheDocument();
});
test('should show all todos', () => {
const filterSelect = screen.getByLabelText(/show:/i);
fireEvent.change(filterSelect, { target: { value: 'all' } });
expect(screen.getByText('Active todo')).toBeInTheDocument();
expect(screen.getByText('Completed todo')).toBeInTheDocument();
});
});
// ============================================
// TEST 6: SORT
// ============================================
describe('Sort', () => {
test('should sort by date (newest first)', async () => {
render(<TodoApp />);
await addTodo('First');
await addTodo('Second');
await addTodo('Third');
const sortSelect = screen.getByLabelText(/sort by:/i);
fireEvent.change(sortSelect, { target: { value: 'date' } });
const todos = screen.getAllByRole('listitem');
expect(todos[0]).toHaveTextContent('Third');
expect(todos[1]).toHaveTextContent('Second');
expect(todos[2]).toHaveTextContent('First');
});
test('should sort alphabetically', async () => {
render(<TodoApp />);
await addTodo('Zebra');
await addTodo('Apple');
await addTodo('Mango');
const sortSelect = screen.getByLabelText(/sort by:/i);
fireEvent.change(sortSelect, { target: { value: 'alphabetical' } });
const todos = screen.getAllByRole('listitem');
expect(todos[0]).toHaveTextContent('Apple');
expect(todos[1]).toHaveTextContent('Mango');
expect(todos[2]).toHaveTextContent('Zebra');
});
test('should sort by priority', async () => {
render(<TodoApp />);
await addTodo('Low priority');
await addTodo('High priority');
await addTodo('Medium priority');
// Set priorities
const setPriority = (text: string, value: string) => {
const todoItem = screen
.getByText(text)
.closest('.todo-item') as HTMLElement;
const select = within(todoItem).getByRole('combobox');
fireEvent.change(select, { target: { value } });
};
setPriority('Low priority', 'low');
setPriority('High priority', 'high');
setPriority('Medium priority', 'medium');
const sortSelect = screen.getByLabelText(/sort by:/i);
fireEvent.change(sortSelect, { target: { value: 'priority' } });
const todos = screen.getAllByRole('listitem');
expect(todos[0]).toHaveTextContent('High priority');
expect(todos[1]).toHaveTextContent('Medium priority');
expect(todos[2]).toHaveTextContent('Low priority');
});
});
// ============================================
// TEST 7: FILTER + SORT COMBINATION
// ============================================
describe('Filter and Sort Combination', () => {
test('should apply both filter and sort', async () => {
render(<TodoApp />);
await addTodo('B active');
await addTodo('A active');
await addTodo('C completed');
const checkbox = getTodoCheckbox('C completed');
fireEvent.click(checkbox);
// Filter active
const filterSelect = screen.getByLabelText(/show:/i);
fireEvent.change(filterSelect, { target: { value: 'active' } });
// Sort alphabetically
const sortSelect = screen.getByLabelText(/sort by:/i);
fireEvent.change(sortSelect, { target: { value: 'alphabetical' } });
const todos = screen.getAllByRole('listitem');
expect(todos).toHaveLength(2);
expect(todos[0]).toHaveTextContent('A active');
expect(todos[1]).toHaveTextContent('B active');
});
});
// ============================================
// TEST 8: CLEAR COMPLETED
// ============================================
describe('Clear Completed', () => {
test('should clear all completed todos', async () => {
render(<TodoApp />);
await addTodo('Active 1');
await addTodo('Complete 1');
await addTodo('Complete 2');
const checkbox1 = getTodoCheckbox('Complete 1');
const checkbox2 = getTodoCheckbox('Complete 2');
fireEvent.click(checkbox1);
fireEvent.click(checkbox2);
const clearButton = screen.getByRole('button', {
name: /clear completed/i,
});
fireEvent.click(clearButton);
expect(screen.getByText('Active 1')).toBeInTheDocument();
expect(screen.queryByText('Complete 1')).not.toBeInTheDocument();
expect(screen.queryByText('Complete 2')).not.toBeInTheDocument();
});
test('should disable clear completed when no completed todos', () => {
render(<TodoApp />);
const clearButton = screen.getByRole('button', {
name: /clear completed/i,
});
expect(clearButton).toBeDisabled();
});
test('should show count of completed todos in button', async () => {
render(<TodoApp />);
await addTodo('Todo 1');
await addTodo('Todo 2');
const checkbox1 = getTodoCheckbox('Todo 1');
fireEvent.click(checkbox1);
const clearButton = screen.getByRole('button', {
name: /clear completed \(1\)/i,
});
expect(clearButton).toBeInTheDocument();
});
});
// ============================================
// TEST 9: TOGGLE ALL
// ============================================
describe('Toggle All', () => {
test('should mark all todos as completed', async () => {
render(<TodoApp />);
await addTodo('Todo 1');
await addTodo('Todo 2');
await addTodo('Todo 3');
const toggleAllButton = screen.getByRole('button', {
name: /toggle all/i,
});
fireEvent.click(toggleAllButton);
const checkbox1 = getTodoCheckbox('Todo 1');
const checkbox2 = getTodoCheckbox('Todo 2');
const checkbox3 = getTodoCheckbox('Todo 3');
expect(checkbox1).toBeChecked();
expect(checkbox2).toBeChecked();
expect(checkbox3).toBeChecked();
});
test('should mark all todos as incomplete if some are completed', async () => {
render(<TodoApp />);
await addTodo('Todo 1');
await addTodo('Todo 2');
const checkbox1 = getTodoCheckbox('Todo 1');
fireEvent.click(checkbox1);
const toggleAllButton = screen.getByRole('button', {
name: /toggle all/i,
});
fireEvent.click(toggleAllButton);
const checkbox2 = getTodoCheckbox('Todo 2');
// Should toggle ALL to completed (because some were active)
expect(checkbox1).toBeChecked();
expect(checkbox2).toBeChecked();
});
test('should disable toggle all when no todos', () => {
render(<TodoApp />);
const toggleAllButton = screen.getByRole('button', {
name: /toggle all/i,
});
expect(toggleAllButton).toBeDisabled();
});
});
// ============================================
// TEST 10: EDIT TODO
// ============================================
describe('Edit Todo', () => {
test('should enter edit mode on edit button click', async () => {
render(<TodoApp />);
await addTodo('Original text');
const editButton = getEditButton('Original text');
fireEvent.click(editButton);
const editInput = screen.getByDisplayValue('Original text');
expect(editInput).toBeInTheDocument();
expect(editInput).toHaveFocus();
});
test('should save edited text on blur', async () => {
render(<TodoApp />);
await addTodo('Original');
const editButton = getEditButton('Original');
fireEvent.click(editButton);
const editInput = screen.getByDisplayValue('Original');
await userEvent.clear(editInput);
await userEvent.type(editInput, 'Edited');
fireEvent.blur(editInput);
expect(screen.getByText('Edited')).toBeInTheDocument();
expect(screen.queryByText('Original')).not.toBeInTheDocument();
});
test('should save edited text on Enter key', async () => {
render(<TodoApp />);
await addTodo('Original');
const editButton = getEditButton('Original');
fireEvent.click(editButton);
const editInput = screen.getByDisplayValue('Original');
await userEvent.clear(editInput);
await userEvent.type(editInput, 'Edited{enter}');
expect(screen.getByText('Edited')).toBeInTheDocument();
});
test('should cancel edit on Escape key', async () => {
render(<TodoApp />);
await addTodo('Original');
const editButton = getEditButton('Original');
fireEvent.click(editButton);
const editInput = screen.getByDisplayValue('Original');
await userEvent.clear(editInput);
await userEvent.type(editInput, 'Should not save');
fireEvent.keyDown(editInput, { key: 'Escape' });
expect(screen.getByText('Original')).toBeInTheDocument();
expect(
screen.queryByText('Should not save')
).not.toBeInTheDocument();
});
test('should revert to original text if edit is empty', async () => {
render(<TodoApp />);
await addTodo('Original');
const editButton = getEditButton('Original');
fireEvent.click(editButton);
const editInput = screen.getByDisplayValue('Original');
await userEvent.clear(editInput);
fireEvent.blur(editInput);
expect(screen.getByText('Original')).toBeInTheDocument();
});
test('should update priority', async () => {
render(<TodoApp />);
await addTodo('Test priority');
const todoItem = screen
.getByText('Test priority')
.closest('.todo-item') as HTMLElement;
const prioritySelect = within(todoItem).getByRole('combobox');
expect(prioritySelect).toHaveValue('low');
fireEvent.change(prioritySelect, { target: { value: 'high' } });
expect(prioritySelect).toHaveValue('high');
});
});
// ============================================
// TEST 11: STATS
// ============================================
describe('Stats', () => {
test('should display correct stats', async () => {
render(<TodoApp />);
await addTodo('Todo 1');
await addTodo('Todo 2');
await addTodo('Todo 3');
const checkbox = getTodoCheckbox('Todo 1');
fireEvent.click(checkbox);
expect(screen.getByText(/total:/i).nextSibling?.textContent).toBe(
'3'
);
expect(screen.getByText(/active:/i).nextSibling?.textContent).toBe(
'2'
);
expect(
screen.getByText(/completed:/i).nextSibling?.textContent
).toBe('1');
});
test('should show empty state message when no todos', () => {
render(<TodoApp />);
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});
});
// ============================================
// TEST 12: ACCESSIBILITY
// ============================================
describe('Accessibility', () => {
test('should have proper ARIA labels', async () => {
render(<TodoApp />);
await addTodo('Test todo');
expect(screen.getByLabelText(/new todo/i)).toBeInTheDocument();
expect(screen.getByLabelText(/show:/i)).toBeInTheDocument();
expect(screen.getByLabelText(/sort by:/i)).toBeInTheDocument();
});
test('should show error with aria-invalid', async () => {
render(<TodoApp />);
const input = screen.getByPlaceholderText(/what needs to be done/i);
const addButton = screen.getByRole('button', { name: /add todo/i });
fireEvent.click(addButton);
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});Exercise 3: Multi-Step Form
jsx
function MultiStepForm() {
// TODO:
// 1. Current step state (1, 2, 3)
// 2. Form data state cho mỗi step:
// Step 1: Personal info (name, email, phone)
// Step 2: Address (street, city, postal code)
// Step 3: Payment (card number, expiry, cvv)
// 3. Errors state cho mỗi step
// 4. Validation cho mỗi step
// 5. Nút: Next, Previous, Submit
// 6. Progress bar
// 7. Review tất cả data ở step cuối
// 8. Không cho next nếu step hiện tại invalid
return <div>{/* Your code */}</div>;
}Exercise 4: Quiz App
jsx
const quizData = [
{
id: 1,
question: 'React được tạo bởi?',
options: ['Google', 'Facebook', 'Microsoft', 'Apple'],
correctAnswer: 1,
},
// More questions...
];
function QuizApp() {
// TODO:
// 1. Current question index state
// 2. Selected answers state (array)
// 3. Show result state (boolean)
// 4. Time remaining state (optional - countdown timer)
// 5. Chức năng:
// - Select answer
// - Next question
// - Previous question
// - Submit quiz
// - Show score
// - Restart quiz
// 6. Highlight correct/incorrect answers khi submit
// 7. Progress indicator
// 8. Prevent changing answer after submit
return <div>{/* Your code */}</div>;
}Exercise 5: Expense Tracker (Challenge)
jsx
function ExpenseTracker() {
// TODO:
// 1. Expenses array state: { id, description, amount, category, date }
// 2. Categories: ['Ăn uống', 'Di chuyển', 'Giải trí', 'Mua sắm', 'Khác']
// 3. Filter state: { category, dateRange, minAmount, maxAmount }
// 4. Form state cho add/edit expense
// 5. Chức năng:
// - Add expense
// - Edit expense
// - Delete expense
// - Filter by category
// - Filter by date range
// - Filter by amount range
// - Search by description
// 6. Thống kê:
// - Tổng chi tiêu
// - Chi tiêu theo category (pie chart hoặc bars)
// - Chi tiêu theo tháng
// - Category chi nhiều nhất
// 7. Sort options: date, amount, category
// 8. Export data (JSON)
// 9. Import data
// 10. LocalStorage persistence
const [expenses, setExpenses] = useState(() => {
const saved = localStorage.getItem('expenses');
return saved ? JSON.parse(saved) : [];
});
const [formData, setFormData] = useState({
description: '',
amount: '',
category: 'Ăn uống',
date: new Date().toISOString().split('T')[0],
});
const [filters, setFilters] = useState({
category: 'all',
searchTerm: '',
dateFrom: '',
dateTo: '',
minAmount: '',
maxAmount: '',
});
const [editingId, setEditingId] = useState(null);
// TODO: Implement các functions:
// - addExpense
// - updateExpense
// - deleteExpense
// - getFilteredExpenses
// - getStatistics
// - exportData
// - importData
return (
<div className='expense-tracker'>
<h1>Quản Lý Chi Tiêu</h1>
{/* Add/Edit Form */}
<div className='expense-form'>{/* Your form code */}</div>
{/* Filters */}
<div className='filters'>{/* Your filters code */}</div>
{/* Statistics */}
<div className='statistics'>{/* Your statistics code */}</div>
{/* Expense List */}
<div className='expense-list'>{/* Your list code */}</div>
</div>
);
}✅ PHẦN 4: REVIEW & CHECKLIST (15-30 phút)
useState Basics:
- [ ] Syntax:
const [state, setState] = useState(initialValue) - [ ] Naming convention:
[thing, setThing] - [ ] Hooks phải ở top level
- [ ] Không được trong if/loop/nested function
State Types:
- [ ] Primitives: number, string, boolean
- [ ] Objects: dùng spread
{...obj, key: value} - [ ] Arrays: dùng spread
[...arr, item]hoặc methods nhưmap,filter - [ ] Tránh mutating methods:
push,pop,splice,sort
Lazy Initialization:
- [ ] Dùng function:
useState(() => expensiveCalculation()) - [ ] Chỉ chạy một lần khi mount
- [ ] Dùng cho localStorage, tính toán phức tạp
Functional Updates:
- [ ] Syntax:
setState(prev => newValue) - [ ] Dùng khi state mới phụ thuộc state cũ
- [ ] Tránh stale closure trong useEffect/timers
Immutability:
- [ ] KHÔNG mutate state trực tiếp
- [ ] Luôn tạo object/array MỚI
- [ ] Dùng spread operator
- [ ] React so sánh bằng reference
Best Practices:
- [ ] Nhóm related state
- [ ] Tránh redundant state (derived state)
- [ ] Tránh duplicate props vào state
- [ ] Structure state tốt (flat hoặc normalized)
Common Mistakes:
jsx
// ❌ Mutate state trực tiếp
user.name = 'New'; // NEVER!
setUser(user);
// ❌ Không dùng functional update
setCount(count + 1);
setCount(count + 1); // Vẫn chỉ +1, không phải +2
// ❌ Hooks trong điều kiện
if (condition) {
const [state, setState] = useState(0); // Error!
}
// ❌ Array mutation
todos.push(newTodo); // NEVER!
setTodos(todos);
// ❌ Redundant state
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // Không cần!
// ✅ ĐÚNG
setUser({ ...user, name: 'New' });
setCount((prev) => prev + 1);
setCount((prev) => prev + 1); // Đúng +2
// Top level
const [state, setState] = useState(0);
setTodos([...todos, newTodo]);
// Derived state
const fullName = `${firstName} ${lastName}`;🎯 HOMEWORK
1. Notes App
Tạo ứng dụng ghi chú:
- CRUD operations (Create, Read, Update, Delete)
- Categories/Tags
- Search functionality
- Rich text formatting (optional)
- Pin/Favorite notes
- Sort by: date, title, modified
- LocalStorage persistence
- Export/Import notes
2. Budget Planner
Quản lý ngân sách cá nhân:
jsx
// State structure
{
income: { amount, source, date },
expenses: [{ amount, category, date, recurring }],
budget: { category: limit },
savings: { goal, current, deadline }
}
// Features:
// - Set income
// - Add/edit/delete expenses
// - Set budget limits per category
// - Alerts khi vượt budget
// - Savings goal tracker
// - Monthly/yearly reports
// - Recurring expenses3. Habit Tracker
Theo dõi thói quen hàng ngày:
jsx
// State structure
{
habits: [
{
id,
name,
goal, // 'daily', 'weekly', số lần
streak,
history: { date: completed },
},
];
}
// Features:
// - Add/edit/delete habits
// - Mark as completed
// - Current streak
// - Best streak
// - Calendar view
// - Statistics
// - Reminders (optional)4. Recipe Book
Sổ công thức nấu ăn:
jsx
// State structure
{
recipes: [
{
id,
title,
ingredients: [{ name, amount, unit }],
steps: [],
prepTime,
cookTime,
servings,
difficulty,
category,
tags,
image,
rating,
notes
}
],
shoppingList: []
}
// Features:
// - Add/edit/delete recipes
// - Search and filter
// - Scale servings
// - Add to shopping list
// - Rate recipes
// - Categories and tags
// - Favorite recipes5. Pomodoro Timer với Stats (Challenge)
Timer làm việc với thống kê:
jsx
// State structure
{
timer: {
minutes,
seconds,
isRunning,
mode // 'work', 'shortBreak', 'longBreak'
},
settings: {
workDuration,
shortBreakDuration,
longBreakDuration,
sessionsUntilLongBreak
},
sessions: [
{
date,
completedPomodoros,
totalFocusTime,
tasks: [{ name, pomodoros }]
}
],
currentTask: { name, estimatedPomodoros, completed }
}
// Features:
// - Customizable durations
// - Auto-switch between work/break
// - Task list với pomodoro estimates
// - Daily/weekly statistics
// - Focus time trends
// - Browser notifications
// - Sound alerts
// - Background work tracking📚 Đọc Thêm
Official Docs:
- React - useState
- React - State as a Snapshot
- React - Queueing State Updates
- React - Updating Objects in State
- React - Updating Arrays in State
Advanced Topics:
📝 Key Takeaways
- useState = State Management cơ bản nhất trong React hooks
- Immutability is KEY - Luôn tạo object/array mới
- Functional Updates - Dùng khi state mới phụ thuộc state cũ
- Lazy Initialization - Optimize performance cho initial state phức tạp
- State Structure - Thiết kế tốt giúp code dễ maintain
- Avoid Redundancy - Derive state thay vì duplicate
- Batching - React tự động batch multiple setState calls
🔍 Debug Tips
1. State không update:
jsx
// Check: Có mutate trực tiếp không?
user.name = 'New'; // ❌
setUser(user); // Không trigger re-render
// Fix:
setUser({ ...user, name: 'New' }); // ✅2. Stale closure:
jsx
// Problem:
useEffect(() => {
setInterval(() => {
setCount(count + 1); // count luôn là giá trị ban đầu
}, 1000);
}, []); // Empty deps
// Fix:
useEffect(() => {
setInterval(() => {
setCount((prev) => prev + 1); // ✅ Functional update
}, 1000);
}, []);3. useState không chạy lazy init:
jsx
// Wrong:
useState(expensiveFunction()); // Chạy mỗi render
// Right:
useState(() => expensiveFunction()); // Chỉ chạy lần đầu💡 Pro Tips
- DevTools: Dùng React DevTools để inspect state changes
- Immer: Thư viện giúp viết immutable updates dễ hơn
- TypeScript: Type safety cho state rất hữu ích
- Console.log: Log state để debug, nhưng nhớ xóa sau khi xong
- Small Components: Tách component nhỏ = state dễ quản lý hơn
🎮 Quick Quiz
Trước khi qua ngày 7, test kiến thức:
- Tại sao phải dùng spread operator khi update object/array state?
- Khi nào dùng functional update
setState(prev => ...)? - Khi nào cần lazy initialization?
- Array methods nào an toàn cho setState? (map, filter, slice...)
- Làm sao update nested object trong state?
Đáp án:
- React so sánh bằng reference. Cùng reference = không re-render.
- Khi state mới phụ thuộc state cũ, hoặc trong useEffect/timer.
- Khi initial state tốn tài nguyên: localStorage, parsing, computation.
- Non-mutating methods: map, filter, slice, concat, spread
- Dùng nested spread:
{ ...obj, nested: { ...obj.nested, key: value } }
🚀 Ngày mai (Ngày 7): useReducer - Complex State Logic! Khi useState không đủ mạnh! 💪