Skip to content

Solution: React + 3 Essential Hooks

ลองเล่น React Todo List ที่สมบูรณ์แล้ว — ลองเพิ่ม todo, filter ด้วย All/Active/Completed, และลบ todo ดู

App.jsx Complete Demo INTERACTIVE

React Todo

2 items remaining

    โปรเจกต์นี้ประกอบด้วย 4 ไฟล์หลัก:

    • App.jsx — Component หลัก เก็บ state ทั้งหมด + useEffect (localStorage) + useMemo (filter)
    • TodoForm.jsx — Form สำหรับเพิ่ม todo ใหม่
    • TodoItem.jsx — แสดง todo แต่ละตัว พร้อม toggle + delete
    • FilterBar.jsx — ปุ่ม filter (All / Active / Completed)

    Show App.jsx
    src/App.jsx
    import { useState, useEffect, useMemo } from "react";
    import TodoForm from "./components/TodoForm";
    import TodoItem from "./components/TodoItem";
    import FilterBar from "./components/FilterBar";
    function App() {
    // === useState: จัดการ state ทั้งหมด ===
    const [todos, setTodos] = useState([]);
    const [filter, setFilter] = useState("all"); // "all" | "active" | "completed"
    // === useEffect #1: อ่าน todos จาก localStorage ตอน mount ===
    useEffect(() => {
    try {
    const saved = localStorage.getItem("react-todos");
    if (saved) {
    setTodos(JSON.parse(saved));
    }
    } catch (err) {
    console.error("Failed to load todos:", err);
    }
    }, []); // [] = ทำครั้งเดียวตอน component โหลด
    // === useEffect #2: บันทึก todos ลง localStorage ทุกครั้งที่เปลี่ยน ===
    useEffect(() => {
    localStorage.setItem("react-todos", JSON.stringify(todos));
    }, [todos]); // [todos] = ทำเมื่อ todos เปลี่ยน
    // === useMemo: filter todos ตาม filter state ===
    const filteredTodos = useMemo(() => {
    switch (filter) {
    case "active":
    return todos.filter((t) => !t.completed);
    case "completed":
    return todos.filter((t) => t.completed);
    default:
    return todos;
    }
    }, [todos, filter]);
    // === useMemo: นับจำนวน todo ที่ยังไม่เสร็จ ===
    const remainingCount = useMemo(() => {
    return todos.filter((t) => !t.completed).length;
    }, [todos]);
    // === Handler functions ===
    const addTodo = (text) => {
    if (!text.trim()) return;
    setTodos([...todos, { id: Date.now(), text: text.trim(), completed: false }]);
    };
    const toggleTodo = (id) => {
    setTodos(
    todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
    };
    const deleteTodo = (id) => {
    setTodos(todos.filter((t) => t.id !== id));
    };
    return (
    <div className="min-h-screen bg-gray-50 py-8 px-4">
    <div className="max-w-lg mx-auto">
    {/* Header */}
    <div className="text-center mb-8">
    <h1 className="text-3xl font-bold text-gray-900">React Todo</h1>
    <p className="text-gray-500 mt-1">
    {remainingCount} {remainingCount === 1 ? "task" : "tasks"} remaining
    </p>
    </div>
    {/* Todo Form */}
    <TodoForm onAddTodo={addTodo} />
    {/* Filter Bar */}
    <FilterBar filter={filter} onFilterChange={setFilter} />
    {/* Todo List */}
    <div className="space-y-2">
    {filteredTodos.length === 0 ? (
    <p className="text-center text-gray-400 py-8">
    {filter === "all"
    ? "No todos yet. Add one above!"
    : `No ${filter} todos.`}
    </p>
    ) : (
    filteredTodos.map((todo) => (
    <TodoItem
    key={todo.id}
    todo={todo}
    onToggle={toggleTodo}
    onDelete={deleteTodo}
    />
    ))
    )}
    </div>
    </div>
    </div>
    );
    }
    export default App;

    จุดสำคัญ:

    • useState 2 ตัว: todos (array) และ filter (string)
    • useEffect 2 ตัว: อ่าน localStorage ตอน mount + บันทึกทุกครั้งที่ todos เปลี่ยน
    • useMemo 2 ตัว: filter todos ตาม filter state + นับ remaining count
    • Handler functions (addTodo, toggleTodo, deleteTodo) สร้าง array ใหม่ทุกครั้ง ไม่ mutate state

    Show TodoForm.jsx
    src/components/TodoForm.jsx
    import { useState } from "react";
    function TodoForm({ onAddTodo }) {
    const [input, setInput] = useState("");
    const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    onAddTodo(input);
    setInput("");
    };
    return (
    <form onSubmit={handleSubmit} className="flex gap-2 mb-6">
    <input
    type="text"
    value={input}
    onChange={(e) => setInput(e.target.value)}
    placeholder="What needs to be done?"
    className="flex-1 px-4 py-2.5 border border-gray-300 rounded-lg
    focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent
    bg-white text-gray-900 placeholder-gray-400"
    />
    <button
    type="submit"
    className="px-6 py-2.5 bg-pink-600 text-white font-medium rounded-lg
    hover:bg-pink-700 transition-colors
    disabled:opacity-50 disabled:cursor-not-allowed"
    disabled={!input.trim()}
    >
    Add
    </button>
    </form>
    );
    }
    export default TodoForm;

    จุดสำคัญ:

    • input state อยู่ใน TodoForm เอง (ไม่ต้อง lift up เพราะ App ไม่ต้องใช้)
    • onAddTodo เป็น callback prop ที่ส่งจาก App
    • ใช้ form + onSubmit เพื่อรองรับทั้งปุ่ม Add และกด Enter
    • e.preventDefault() ป้องกัน page reload

    Show TodoItem.jsx
    src/components/TodoItem.jsx
    function TodoItem({ todo, onToggle, onDelete }) {
    return (
    <div className="flex items-center gap-3 p-3 bg-white rounded-lg border border-gray-200
    hover:border-gray-300 transition-colors group">
    {/* Checkbox */}
    <input
    type="checkbox"
    checked={todo.completed}
    onChange={() => onToggle(todo.id)}
    className="w-5 h-5 rounded border-gray-300 text-pink-600
    focus:ring-pink-500 cursor-pointer"
    />
    {/* Todo text */}
    <span
    className={`flex-1 text-sm transition-all ${
    todo.completed
    ? "line-through text-gray-400"
    : "text-gray-700"
    }`}
    >
    {todo.text}
    </span>
    {/* Delete button — shows on hover */}
    <button
    onClick={() => onDelete(todo.id)}
    className="text-gray-400 hover:text-red-500 transition-colors
    opacity-0 group-hover:opacity-100"
    aria-label={`Delete "${todo.text}"`}
    >
    <svg
    xmlns="http://www.w3.org/2000/svg"
    className="h-5 w-5"
    fill="none"
    viewBox="0 0 24 24"
    stroke="currentColor"
    strokeWidth={2}
    >
    <path
    strokeLinecap="round"
    strokeLinejoin="round"
    d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
    />
    </svg>
    </button>
    </div>
    );
    }
    export default TodoItem;

    จุดสำคัญ:

    • ไม่มี state ของตัวเอง — เป็น “presentational component” ล้วนๆ
    • รับ todo object + callback functions เป็น props
    • ใช้ group + group-hover:opacity-100 ของ Tailwind เพื่อแสดงปุ่ม delete ตอน hover
    • aria-label สำหรับ accessibility

    Show FilterBar.jsx
    src/components/FilterBar.jsx
    const FILTERS = [
    { key: "all", label: "All" },
    { key: "active", label: "Active" },
    { key: "completed", label: "Completed" },
    ];
    function FilterBar({ filter, onFilterChange }) {
    return (
    <div className="flex gap-1 mb-4 p-1 bg-gray-100 rounded-lg">
    {FILTERS.map((f) => (
    <button
    key={f.key}
    onClick={() => onFilterChange(f.key)}
    className={`flex-1 py-1.5 px-3 text-sm font-medium rounded-md transition-all ${
    filter === f.key
    ? "bg-white text-pink-600 shadow-sm"
    : "text-gray-500 hover:text-gray-700"
    }`}
    >
    {f.label}
    </button>
    ))}
    </div>
    );
    }
    export default FilterBar;

    จุดสำคัญ:

    • FILTERS array อยู่นอก component เพราะไม่เปลี่ยนแปลง (ไม่ต้อง re-create ทุก render)
    • ใช้ .map() สร้างปุ่มจาก array แทนเขียนซ้ำ 3 ครั้ง
    • Active filter ได้ style ต่างจากตัวอื่น (bg-white + shadow)
    • ไม่มี state ของตัวเอง — ใช้ filter + onFilterChange จาก App

    สรุป Hooks ที่ใช้ในโปรเจกต์

    Section titled “สรุป Hooks ที่ใช้ในโปรเจกต์”
    Hookใช้ที่ไหนทำอะไร
    useStateApp.jsxเก็บ todos array + filter string
    useStateTodoForm.jsxเก็บ input text สำหรับ form
    useEffectApp.jsxอ่าน todos จาก localStorage ตอน mount
    useEffectApp.jsxบันทึก todos ลง localStorage เมื่อเปลี่ยน
    useMemoApp.jsxFilter todos ตาม filter state
    useMemoApp.jsxนับจำนวน todo ที่ยังไม่เสร็จ