Skip to content

📅 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 array

1.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

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 expenses

3. 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 recipes

5. 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:

Advanced Topics:


📝 Key Takeaways

  1. useState = State Management cơ bản nhất trong React hooks
  2. Immutability is KEY - Luôn tạo object/array mới
  3. Functional Updates - Dùng khi state mới phụ thuộc state cũ
  4. Lazy Initialization - Optimize performance cho initial state phức tạp
  5. State Structure - Thiết kế tốt giúp code dễ maintain
  6. Avoid Redundancy - Derive state thay vì duplicate
  7. 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

  1. DevTools: Dùng React DevTools để inspect state changes
  2. Immer: Thư viện giúp viết immutable updates dễ hơn
  3. TypeScript: Type safety cho state rất hữu ích
  4. Console.log: Log state để debug, nhưng nhớ xóa sau khi xong
  5. 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:

  1. Tại sao phải dùng spread operator khi update object/array state?
  2. Khi nào dùng functional update setState(prev => ...)?
  3. Khi nào cần lazy initialization?
  4. Array methods nào an toàn cho setState? (map, filter, slice...)
  5. Làm sao update nested object trong state?

Đáp án:

  1. React so sánh bằng reference. Cùng reference = không re-render.
  2. Khi state mới phụ thuộc state cũ, hoặc trong useEffect/timer.
  3. Khi initial state tốn tài nguyên: localStorage, parsing, computation.
  4. Non-mutating methods: map, filter, slice, concat, spread
  5. 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! 💪

Personal tech knowledge base