Bài 6: Thực hành với Todo List App

Chúc mừng bạn đã đi đến chặng cuối của series React cơ bản! Chúng ta đã có trong tay: Props (truyền dữ liệu), State (quản lý trạng thái), useEffect (vòng đời/API), và Context (dữ liệu toàn cục).
Hôm nay, chúng ta sẽ lắp ghép tất cả để xây dựng một ứng dụng Todo List – “bài kiểm tra” kinh điển cho mọi React Developer.
Mục tiêu ứng dụng
Ứng dụng của chúng ta sẽ thực hiện 4 tính năng chính (CRUD):
- Create: Thêm công việc mới.
- Read: Hiển thị danh sách công việc.
- Update: Đánh dấu hoàn thành.
- Delete: Xóa công việc.
Cấu trúc dữ liệu (State)
Chúng ta sẽ quản lý một mảng các đối tượng todo bằng todos state, khai báo kiểu dữ liệu Todo trong Typescript:
type Todo = { id: number; text: string; completed: boolean; }; const [todos, setTodos] = useState<Todo[]>([]);
Thực hành
Bước 1: Tạo file TodoList.tsx
Đầu tiên, chúng ta sẽ tạo một component TodoList đơn giản sử dụng các công cụ như state và event. Trong đó state sẽ quản lý danh sách công việc (todos state), dữ liệu đầu vào (input state) và giá trị cần sửa (editText state). Các event cần có sẽ bao gồm thêm, sửa, xóa, sửa công việc.
import { useState } from "react";
type Todo = {
id: number;
text: string;
completed: boolean;
};
export default function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState<string>("");
// State cho việc sửa: lưu id của item đang sửa và nội dung sửa
const [editingId, setEditingId] = useState<number | null>(null);
const [editText, setEditText] = useState<string>("");
// Thêm
const addTodo = () => {
if (!input) return;
setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
setInput("");
};
// Xóa
const deleteTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// Hoàn thành/chưa
const completeTodo = (id: number) => {
setTodos(
todos.map((todo) => ({
...todo,
completed: todo.id === id ? !todo.completed : todo.completed,
}))
);
};
// Bắt đầu sửa
const startEdit = (id: number, text: string) => {
setEditingId(id);
setEditText(text);
};
// Lưu nội dung đã sửa
const saveEdit = (id: number) => {
setTodos(todos.map(todo => todo.id === id ? { ...todo, text: editText } : todo));
setEditingId(null);
};
return (
<div>
{/* Form nhập liệu */}
<form onSubmit={(e) => e.preventDefault()}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" onClick={addTodo}>
Thêm
</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{editingId === todo.id ? (
// Nếu đang ở chế độ sửa thì hiện ô input và nút Lưu
<>
<input
value={editText}
onChange={(e) => setEditText(e.target.value)}
/>
<button onClick={() => saveEdit(todo.id)}>Lưu</button>
</>
) : (
// Nếu không thì hiện text bình thường kèm nút Sửa và Xóa
<>
<span onClick={() => /* logic toggle ở đây */ null}>
{" "}
{todo.text}{" "}
</span>
<button onClick={() => startEdit(todo.id, todo.text)}>
Sửa
</button>
<button onClick={() => deleteTodo(todo.id)}>Xóa</button>
<input
type="checkbox"
checked={todo.completed}
onChange={() => completeTodo(todo.id)}
/>
</>
)}
</li>
))}
</ul>
</div>
);
}
Bước 2: Tích hợp lưu trữ cục bộ với localStorage
Ở bước này, chúng ta sẽ tích hợp thêm localStorage để phục vụ cho việc lưu trữ danh sách công việc và sử dụng useEffect để xử lý việc đọc ghi data từ localStorage (Side Effect) và quản lý vòng đời (Lifecycle).
Mặc định khi bạn F5 để tải lại cửa sổ trình duyệt, mọi state sẽ bị reset. useEffect sẽ giúp bạn lưu lại danh sách todos vào localStorage và tự động khôi phục nó mỗi khi trang web tải lại. Đầu tiên bạn sẽ cần viết hàm để lấy danh sách công việc bằng cách đọc từ localStorage (hàm loadTodos) và sử dụng ở useState để lấy dữ liệu trong lần render đầu tiên. Khi danh sách todos thay đổi, hàm useEffect sẽ nhận biết và thực hiện ghi vào localStorage bằng setItem().
// Hàm này tách riêng, có thể unit test dễ const loadTodos = (): Todo[] => { try { const savedTodos = localStorage.getItem("my-todos"); return savedTodos ? JSON.parse(savedTodos) : []; } catch (error) { console.error("Lỗi đọc LocalStorage:", error); return []; } }; export default function TodoList() { // Gọi hàm này vào trong useState để lấy dữ liệu lúc khởi tạo const [todos, setTodos] = useState<Todo[]>(loadTodos); // useEffect lưu dữ liệu mỗi khi state 'todos' useEffect(() => { localStorage.setItem("my-todos", JSON.stringify(todos)); }, [todos]); // ... các hàm khác }
Bước 3: Tạo Context để mở rộng phạm vi của ứng dụng
Trong các dự án thực tế, dữ liệu Todo có thể cần được chia sẻ giữa nhiều component như hiển thị số lượng công việc ở Header, danh sách ở Main. Để làm được điều đó, chúng ta sẽ cần chuyển từ useState cục bộ sang Context API để quản lý trạng thái toàn cục.
Đầu tiên sẽ cần tạo một file context TodoContext.tsx để bao bọc dữ liệu. Các khai báo như type, useState, useEffect, hàm loadTodos sẽ dời sang context quản lý:
import { createContext, useState, ReactNode, useEffect } from "react";
type Todo = { id: number; text: string; completed: boolean };
// Tạo context
export const TodoContext = createContext<{
todos: Todo[];
setTodos: React.Dispatch<React.SetStateAction<Todo[]>>;
} | null>(null);
// loadTodos()
// Context Provider để bọc component và cung cấp các state, hàm
export const TodoProvider = ({ children }: { children: ReactNode }) => {
const [todos, setTodos] = useState<Todo[]>(loadTodos); // hàm loadTodos như bước trước
useEffect(() => {
localStorage.setItem("my-todos", JSON.stringify(todos));
}, [todos]);
return (
<TodoContext.Provider value={{ todos, setTodos }}>
{/* Các component bên trong này sẽ sử dụng được các giá trị: todos,
setTodos */}
{children}
</TodoContext.Provider>
);
};
Bạn cần bao bọc các component bên trong TodoProvider để chúng có thể chia sẻ dữ liệu với nhau. Ở đây chúng ta sẽ bọc ở component có cấp cao nhất là App.tsx, nơi chứa layout chính:
import { TodoProvider } from "./TodoContext";
import TodoList from "./TodoList";
function App() {
return (
<TodoProvider>
<TodoList />
</TodoProvider>
);
}
Quay lại component TodoList, các khai báo type, state, event sau khi đã dời qua context, Component giờ đây chỉ còn nhiệm vụ lấy dữ liệu từ Context để hiển thị và xử lý các event cục bộ:
import { useContext, useState } from "react";
import { TodoContext } from "./TodoContext";
function TodoList() {
const context = useContext(TodoContext);
if (!context) return null; // Xử lý trường hợp lỗi do quên bọc Provider
const { todos, setTodos } = context; // Trích xuát các giá trị của context cung cấp
const [input, setInput] = useState("");
const addTodo = () => {
if (!input) return;
setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
setInput("");
};
// ... các hàm khác ...
return (
<div>
<form onSubmit={(e) => e.preventDefault()}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" onClick={addTodo}>
Thêm
</button>
</form>
{/* Hiển thị danh sách todos lấy từ context, code không đổi */}
</div>
);
}
Bổ sung thêm file Header.tsx chứa component Header cũng sử dụng dữ liệu từ context để hiển thị số lượng công việc khi danh sách todos thay đổi:
import { useContext } from "react";
import { TodoContext } from "./TodoContext";
export default function Header() {
const context = useContext(TodoContext);
if (!context) return null; // Tránh lỗi nếu Header chưa được bọc bằng context
const { todos } = context;
const total = todos.length; // Đếm tổng số lượng công việc
const completed = todos.filter((t) => t.completed).length; // Đếm tổng số công việc đã hoàn thành
return (
<header
style={{
padding: 16,
marginBottom: 16,
background: "#eeeeee",
}}
>
<h1>Todo App</h1>
<p>
Tổng: {total} | Đã xong: {completed}
</p>
</header>
);
}
Import thêm Header vào App:
import TodoList from "./TodoList";
import { TodoProvider } from "./TodoContext";
import Header from "./Header";
export default function App() {
return (
<TodoProvider>
<Header />
<TodoList />
</TodoProvider>
);
}
Như vậy chúng ta đã vừa hoàn thiện xong một ứng dụng Todo List với đầy đủ các tính năng cơ bản của CRUD, xử lý Side Effect, lưu trữ dữ liệu ở localStorage và tái sử dụng các logic và state ở phạm vi toàn cục với context.
Các bạn có thể xem code mẫu tại đây.
Tại sao bài tập này lại quan trọng?
- Tư duy Component hóa: Bạn học cách phân chia giao diện. Thay vì viết tất cả vào một file, bạn biết cách tách biệt
Header(hiển thị dữ liệu),TodoList(xử lý logic) vàTodoProvider(quản lý trạng thái), giúp code sạch sẽ và dễ bảo trì. - Immutability (Tính bất biến): Thông qua toán tử spread
[...], bạn nắm vững nguyên tắc quan trọng nhất của React: không bao giờ thay đổi trực tiếp dữ liệu cũ mà luôn tạo ra một bản sao mới. Đây là chìa khóa để React nhận diện sự thay đổi và cập nhật giao diện (re-render) chính xác. - Quản lý Side Effect với
useEffect: Bạn học cách “giao tiếp” với môi trường bên ngoài (nhưlocalStorage). Điều này giúp ứng dụng của bạn không bị “mất trí nhớ” mỗi khi người dùng tải lại trang. - Quản lý trạng thái toàn cục với Context API: Bạn vượt qua giới hạn của việc truyền
propscồng kềnh. Việc đưa dữ liệu vàoContextgiúp mọi component con đều có thể truy cập nguồn dữ liệu “chung” một cách dễ dàng và đồng bộ. - Tư duy chuyên nghiệp: Những kỹ thuật như
key propđể tối ưu render, haylazy initializationđể tối ưu hiệu năng tronguseState, chính là những “bí kíp” tạo nên sự khác biệt giữa một đoạn code chạy được và một đoạn code chất lượng. - Cập nhật dữ liệu (Update): Bạn học cách sử dụng
.map()để tìm và thay đổi một phần tử cụ thể trong mảng. Đây là kỹ thuật “vàng” vì nó giúp React nhận diện được sự thay đổi của state và tự động re-render lại đúng vị trí đó.
Tổng kết series
Bạn đã hoàn thành 6 bài học nền tảng để bước chân vào thế giới của React JS!. Bây giờ, bạn có thể tự tin:
- Tạo giao diện bằng Component.
- Điều khiển dữ liệu bằng State.
- Tương tác với bên ngoài bằng useEffect.
- Chia sẻ dữ liệu toàn cục bằng Context.
- Lưu trữ dữ liệu cục bộ với localStorage.
Chúc bạn sớm xây dựng được những sản phẩm tuyệt vời! 🚀
Và nhớ theo dõi tiếp Series React nâng cao để tìm hiểu sâu các tính năng “nặng đô” hơn và mở rộng kỹ năng thực chiến nhé.





