← Về danh sách bài họcBài 25/25

🚀 Bài 25: Project Cuối - Todo App Hoàn Chỉnh

⏱️ Thời gian đọc: 25 phút | 📚 Độ khó: Nâng cao

🎯 Sau bài học này, bạn sẽ:

1. Setup Project

npm create vite@latest todo-app -- --template react
cd todo-app
npm install
npm run dev

2. Todo Reducer

// hooks/useTodos.js
import { useReducer, useEffect, useCallback, useMemo } from 'react';

function todoReducer(state, action) {
    switch (action.type) {
        case 'ADD':
            return [...state, {
                id: Date.now(), text: action.payload,
                done: false, createdAt: new Date().toISOString()
            }];
        case 'TOGGLE':
            return state.map(t => t.id === action.payload ? { ...t, done: !t.done } : t);
        case 'DELETE':
            return state.filter(t => t.id !== action.payload);
        case 'EDIT':
            return state.map(t => t.id === action.payload.id ? { ...t, text: action.payload.text } : t);
        case 'CLEAR_DONE':
            return state.filter(t => !t.done);
        case 'LOAD':
            return action.payload;
        default: return state;
    }
}

export function useTodos() {
    const [todos, dispatch] = useReducer(todoReducer, [], () => {
        try {
            return JSON.parse(localStorage.getItem('todos')) || [];
        } catch { return []; }
    });

    // Persist to localStorage
    useEffect(() => {
        localStorage.setItem('todos', JSON.stringify(todos));
    }, [todos]);

    const addTodo = useCallback((text) =>
        dispatch({ type: 'ADD', payload: text }), []);
    const toggleTodo = useCallback((id) =>
        dispatch({ type: 'TOGGLE', payload: id }), []);
    const deleteTodo = useCallback((id) =>
        dispatch({ type: 'DELETE', payload: id }), []);
    const editTodo = useCallback((id, text) =>
        dispatch({ type: 'EDIT', payload: { id, text } }), []);
    const clearDone = useCallback(() =>
        dispatch({ type: 'CLEAR_DONE' }), []);

    const stats = useMemo(() => ({
        total: todos.length,
        done: todos.filter(t => t.done).length,
        active: todos.filter(t => !t.done).length,
    }), [todos]);

    return { todos, addTodo, toggleTodo, deleteTodo, editTodo, clearDone, stats };
}

3. Components

// components/TodoInput.jsx
import { useState, useRef, useEffect } from 'react';

export function TodoInput({ onAdd }) {
    const [text, setText] = useState('');
    const inputRef = useRef(null);

    useEffect(() => { inputRef.current.focus(); }, []);

    const handleSubmit = (e) => {
        e.preventDefault();
        if (text.trim()) {
            onAdd(text.trim());
            setText('');
        }
    };

    return (
        <form onSubmit={handleSubmit} className="todo-input">
            <input ref={inputRef} value={text}
                onChange={e => setText(e.target.value)}
                placeholder="Bạn cần làm gì?" />
            <button type="submit" disabled={!text.trim()}>Thêm</button>
        </form>
    );
}

// components/TodoItem.jsx
import { memo, useState } from 'react';

export const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete, onEdit }) {
    const [editing, setEditing] = useState(false);
    const [editText, setEditText] = useState(todo.text);

    const handleSave = () => {
        if (editText.trim()) {
            onEdit(todo.id, editText.trim());
            setEditing(false);
        }
    };

    return (
        <li className={`todo-item ${todo.done ? 'done' : ''}`}>
            <input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
            {editing ? (
                <input value={editText} onChange={e => setEditText(e.target.value)}
                    onBlur={handleSave} onKeyDown={e => e.key === 'Enter' && handleSave()} autoFocus />
            ) : (
                <span onDoubleClick={() => setEditing(true)}>{todo.text}</span>
            )}
            <button onClick={() => onDelete(todo.id)} className="delete">✕</button>
        </li>
    );
});

4. App Component

// App.jsx
import { useState, useMemo } from 'react';
import { useTodos } from './hooks/useTodos';
import { TodoInput } from './components/TodoInput';
import { TodoItem } from './components/TodoItem';

function App() {
    const { todos, addTodo, toggleTodo, deleteTodo, editTodo, clearDone, stats } = useTodos();
    const [filter, setFilter] = useState('all');

    const filteredTodos = useMemo(() => {
        switch (filter) {
            case 'active': return todos.filter(t => !t.done);
            case 'done': return todos.filter(t => t.done);
            default: return todos;
        }
    }, [todos, filter]);

    return (
        <div className="app">
            <h1>📝 Todo App</h1>
            <TodoInput onAdd={addTodo} />

            <div className="filters">
                {['all', 'active', 'done'].map(f => (
                    <button key={f} className={filter === f ? 'active' : ''}
                        onClick={() => setFilter(f)}>
                        {f === 'all' ? 'Tất cả' : f === 'active' ? 'Chưa xong' : 'Đã xong'}
                    </button>
                ))}
            </div>

            <ul className="todo-list">
                {filteredTodos.map(todo => (
                    <TodoItem key={todo.id} todo={todo}
                        onToggle={toggleTodo} onDelete={deleteTodo} onEdit={editTodo} />
                ))}
            </ul>

            <div className="stats">
                <span>{stats.active} chưa xong / {stats.total} tổng</span>
                {stats.done > 0 &&
                    <button onClick={clearDone}>Xóa {stats.done} đã xong</button>
                }
            </div>
        </div>
    );
}

5. Hooks Đã Sử Dụng

HookMục Đích
useReducerQuản lý CRUD logic cho todos
useStateInput text, filter, edit mode
useEffectPersist data vào localStorage
useCallbackMemoize dispatch wrappers
useMemoFilter và tính statistics
useRefAuto-focus input
memoNgăn TodoItem re-render không cần thiết

🎉 Chúc Mừng! Bạn Đã Hoàn Thành Khóa Học!