AniUI Academy

Capstone: A Small, Real Component

Building a filterable, debounced task list — tying together state, effects, a custom hook, memoization, and stable keys into one small component built the way a real one would be.

12 min read

This course has covered each concept in isolation. Real components combine several of them at once. This capstone builds one small, genuinely realistic component — a searchable task list — using deliberately more than one idea from this course together, the way an actual feature would.

What it needs to do

  • Show a list of tasks, each with a completed/not-completed state.
  • Filter the list by a search box, without re-filtering on every single keystroke (debounced).
  • Let the user toggle a task's completed state.
  • Show a live count of completed tasks.

Building it up

Start with the debouncing, extracted as a custom hook — because "wait for a pause before reacting to a fast-changing value" is generic enough to be useful beyond just this one search box:

function useDebouncedValue(value, delayMs) {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id); // cancel if value changes again before delayMs
  }, [value, delayMs]);
 
  return debounced;
}

This is the cleanup-functions pattern applied directly: every keystroke schedules a new timeout, and cancels whatever timeout the previous keystroke had scheduled, so only a genuine pause in typing ever lets a timeout actually fire.

Next, the filtering — memoized, since it depends on both the debounced search text and the task list:

const filteredTasks = useMemo(
  () => tasks.filter((task) => task.title.toLowerCase().includes(debouncedQuery.toLowerCase())),
  [tasks, debouncedQuery]
);

And a derived count, also memoized, recomputed only when the task list itself changes:

const completedCount = useMemo(
  () => tasks.filter((task) => task.completed).length,
  [tasks]
);

For a list this small, memoizing the filter and the count is mostly illustrative — genuinely cheap enough to just recompute every render — but it's the same line of code you'd reach for once the list (or the filtering logic) is large enough to matter, which is why it's worth the practice here.

Toggling a task's completed state follows the same "replace, don't mutate" discipline from the useState lesson:

function toggleTask(id) {
  setTasks((prev) =>
    prev.map((task) => (task.id === id ? { ...task, completed: !task.completed } : task))
  );
}

And each rendered row is keyed by the task's own id — not its position in filteredTasks, which changes with every keystroke:

{filteredTasks.map((task) => (
  <li key={task.id}>
    <label>
      <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
      {task.title}
    </label>
  </li>
))}

Try it yourself

The full component, combining a custom hook, debouncing, memoization, a controlled input, and stable keys:

Try it yourself
Loading playground...

What this pulled together

  • State (useState) for the task list, the raw query, and derived UI.
  • Effects with cleanup (useEffect) for the debounce timer, cancelling a stale timeout before starting a new one.
  • A custom hook (useDebouncedValue) extracting genuinely reusable behavior.
  • Memoization (useMemo) for the filtered list and the completed count.
  • Correct list keys (task.id, not array index) for a list whose membership changes as the user types.
  • Immutable updates (.map producing a new array, {...task} producing a new object) rather than mutating state in place.

None of these are exotic — every one of them is a lesson you've already completed. What real components add isn't new concepts; it's combining several ordinary ones correctly, in the same small amount of code, which is exactly the skill this capstone was built to exercise.

What to remember

  • Real components combine several hooks and patterns together — the individual pieces from this course, not new ones.
  • Extract genuinely reusable behavior (like debouncing) into a custom hook rather than duplicating it inline.
  • Keep list keys tied to stable data identity, especially once a list's membership can change from user input.
  • Memoization is a tool to reach for as a calculation or list actually grows — not evidence, on its own, that a component is "done right."

Check yourself

4 questions · pass 3/4 to finish the course

up to 50
  1. 1.In the capstone's search box, why is the typed input debounced before it's used to filter the list, rather than filtering on every keystroke directly?

  2. 2.In the capstone, why is the debounce logic extracted into a useDebouncedValue custom hook rather than left inline in the component?

  3. 3.Why does the capstone's task list key each row by task.id rather than its index in the filtered array?

  4. 4.Why is the completed-count calculation wrapped in useMemo in the capstone, given the list is small?

4 left to answer