Building the Task Manager: Architecture, Typed Models & the useTasks Hook

Design and implement the component architecture, TypeScript data model, and localStorage-backed custom hook that power a real task manager app, including filtering, search, and a testing strategy.

A task manager looks simple on the surface — add a task, check it off, delete it — but a production-quality implementation has to get several things right at once: a data model that won't let invalid state creep in, a clean split between 'dumb' presentational components and the 'smart' logic that owns state, persistence that survives a page refresh, and derived views (filtered, searched) that stay fast as the list grows. This lesson builds that app piece by piece: a typed Task model, a TaskForm for creating tasks, a TaskList/TaskItem pair for rendering them, and a useTasks custom hook that centralizes all state logic — create, toggle, edit, delete, filter, search — backed by localStorage. By the end you'll see exactly how the pieces fit together, and you'll know how to test the hook in isolation from the UI.

Designing the data model

Every feature in the spec — filtering, search, forms — reads or writes a Task. Get this type wrong and every consumer inherits the mess. The model should capture exactly the states a task can be in, no more and no less: it has an id, a title, an optional description, a completed flag, a priority, and timestamps for created/updated so the UI can sort or display 'last edited'.

Using a union type for priority instead of a raw string means TypeScript will catch a typo like 'hihg' at compile time instead of letting it silently fail a filter comparison at runtime. Similarly, giving id its own branded-ish alias (even just string) documents intent, and generating it with crypto.randomUUID() avoids the classic bug of using array index as a key, which breaks React's reconciliation when items are reordered or deleted.

• Task is the single source of truth — every component receives it as a prop, never re-derives its own shape. • Priority is a closed union ('low' | 'medium' | 'high') so invalid values are impossible to construct. • Timestamps are stored as epoch numbers (Date.now()), which are trivially serializable to JSON for localStorage and easy to sort.

Splitting components: containers vs. presentation

The architecture follows a well-known split: TaskManager is the container that owns state (via useTasks) and wires callbacks down; TaskForm, TaskList, and TaskItem are presentational — they receive data and callback props and render, without knowing where the data came from or how it's persisted. This makes each piece independently testable and reusable: TaskItem doesn't care if its parent list came from localStorage, an API, or a test fixture.

Props are the contract between layers. TaskItem takes a task: Task and two callbacks, onToggle(id) and onDelete(id) — it never mutates task directly, it only asks the parent to do so. This one-way data flow is what keeps state changes traceable: every mutation happens in exactly one place, inside useTasks.

• TaskManager — container: calls useTasks(), renders TaskForm + filter/search controls + TaskList. • TaskForm — controlled inputs for title/description/priority, calls onSubmit(newTaskInput). • TaskList — maps an array of tasks to TaskItem elements, keyed by task.id. • TaskItem — renders one task's checkbox, text, priority badge, and delete button.

Filtering and search as derived state

A common mistake is storing 'filtered tasks' as its own piece of state that you manually keep in sync with the task list — this inevitably drifts out of sync after an edit. The correct pattern is to store only the *inputs* to filtering (the status filter and the search query) as state, and compute the visible list with useMemo every render. That way the filtered result can never be stale: it's always a pure function of the current tasks plus the current filter/search values.

Search should be case-insensitive and match against both title and description. Combining it with the status filter ('all' | 'active' | 'completed') means the two behave independently — a user can search within 'active' tasks only, which is exactly what they'd expect from a real app.

Testing strategy

Because all business logic lives inside useTasks rather than scattered across components, it can be tested with @testing-library/react's renderHook, completely independent of the DOM tree the app actually renders. That's the payoff of the container/presentational split: you get fast, focused unit tests for logic, and separate, simpler render tests for components that just check props map to output correctly.

For localStorage-backed hooks, tests should mock or stub window.localStorage (jsdom provides a working in-memory implementation by default) and assert both the returned state *and* that the stored JSON matches after each action, catching persistence bugs that a state-only assertion would miss.

The Task model and useTasks hook signature

NewTaskInput uses Pick<Task, ...> so the input type can never drift from the source model — add a field to Task and update the pick, not two separate interfaces. visibleTasks is intentionally separate from tasks: components render visibleTasks, but addTask/toggleTask/etc. always operate on the full, canonical tasks array.

// models/task.ts
export type Priority = "low" | "medium" | "high";

export interface Task {
  id: string;
  title: string;
  description?: string;
  completed: boolean;
  priority: Priority;
  createdAt: number;
  updatedAt: number;
}

// Fields a caller supplies when creating a task; the hook fills in the rest.
export type NewTaskInput = Pick<Task, "title" | "description" | "priority">;

export type StatusFilter = "all" | "active" | "completed";

// hooks/useTasks.ts — what every consumer of the hook can rely on
export interface UseTasksResult {
  tasks: Task[];               // full, unfiltered list (persisted)
  visibleTasks: Task[];        // derived: filtered + searched
  statusFilter: StatusFilter;
  searchQuery: string;
  setStatusFilter: (f: StatusFilter) => void;
  setSearchQuery: (q: string) => void;
  addTask: (input: NewTaskInput) => void;
  toggleTask: (id: string) => void;
  editTask: (id: string, patch: Partial<NewTaskInput>) => void;
  deleteTask: (id: string) => void;
}

Code Examples

This hook is the entire 'brain' of the app. State updates always go through the functional form of setTasks (prev => ...) so rapid successive calls never read stale state, and every task list update triggers the useEffect that writes the new state to localStorage. Because visibleTasks is recomputed with useMemo from its true dependencies, there is no separate 'filtered tasks' state to fall out of sync — a bug class this pattern eliminates entirely.

const visibleTasks = useMemo(() => { ... }, [tasks, statusFilter, searchQuery]);

Common mistake: A common bug is forgetting the [tasks] dependency on the persistence useEffect, which silently stops localStorage from ever updating after the first render.

None of these three components import useTasks or touch localStorage — they receive everything through props. That means TaskItem can be rendered in a test with a hand-built fake Task and two jest.fn() callbacks, with zero setup. Keying by task.id (a stable UUID) rather than array index means React correctly preserves each row's identity — and any local UI state inside a row — even when tasks above it are deleted or reordered.

<ul>{tasks.map((task) => <TaskItem key={task.id} ... />)}</ul>

renderHook mounts useTasks without any surrounding component tree, and act batches the state updates that a real event handler would trigger. Asserting on both result.current.tasks and the raw localStorage value catches bugs in either layer. Expected result: Both tests pass: 1 task added and persisted; visibleTasks correctly narrows to one match.

import { useCallback, useEffect, useMemo, useState } from "react";
import type { Task, NewTaskInput, StatusFilter, UseTasksResult } from "../models/task";

const STORAGE_KEY = "task-manager:tasks";

function loadTasks(): Task[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    return raw ? (JSON.parse(raw) as Task[]) : [];
  } catch {
    // Corrupt or missing data should never crash the app — fall back to empty.
    return [];
  }
}

export function useTasks(): UseTasksResult {
  const [tasks, setTasks] = useState<Task[]>(loadTasks);
  const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
  const [searchQuery, setSearchQuery] = useState("");

  // Persist on every change. Runs after render, so it never blocks the UI.
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
  }, [tasks]);

  const addTask = useCallback((input: NewTaskInput) => {
    const now = Date.now();
    const task: Task = {
      id: crypto.randomUUID(),
      title: input.title.trim(),
      description: input.description?.trim() || undefined,
      priority: input.priority,
      completed: false,
      createdAt: now,
      updatedAt: now,
    };
  
…
// TaskForm.tsx — controlled form, validated before calling onSubmit
import { useState, type FormEvent } from "react";
import type { NewTaskInput, Priority } from "../models/task";

interface TaskFormProps {
  onSubmit: (input: NewTaskInput) => void;
}

export function TaskForm({ onSubmit }: TaskFormProps) {
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [priority, setPriority] = useState<Priority>("medium");
  const [error, setError] = useState<string | null>(null);

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!title.trim()) {
      setError("Title is required.");
      return;
    }
    onSubmit({ title, description, priority });
    setTitle("");
    setDescription("");
    setPriority("medium");
    setError(null);
  }

  return (
    <form onSubmit={handleSubmit} aria-label="Add task">
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Task title"
        aria-invalid={!!error}
      />
      <input
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        placeholder="Description (optional)"
      />
  
…
import { renderHook, act } from "@testing-library/react";
import { useTasks } from "./useTasks";

beforeEach(() => {
  localStorage.clear();
});

test("addTask adds a task and persists it", () => {
  const { result } = renderHook(() => useTasks());

  act(() => {
    result.current.addTask({ title: "Write tests", priority: "high" });
  });

  expect(result.current.tasks).toHaveLength(1);
  expect(result.current.tasks[0].title).toBe("Write tests");
  expect(result.current.tasks[0].completed).toBe(false);

  const stored = JSON.parse(localStorage.getItem("task-manager:tasks")!);
  expect(stored).toHaveLength(1);
});

test("visibleTasks respects both status filter and search query", () => {
  const { result } = renderHook(() => useTasks());

  act(() => {
    result.current.addTask({ title: "Buy milk", priority: "low" });
    result.current.addTask({ title: "Ship feature", priority: "high" });
  });
  act(() => {
    result.current.toggleTask(result.current.tasks[0].id); // completes "Ship feature" (newest first)
  });

  act(() => {
    result.current.setStatusFilter("active");
    result.current.setSearchQuery("milk");
  });

  expect(result.current.visibleTasks).toHaveLength(1);
  
…

Task Manager component & data flow

State and persistence live only in useTasks; every component below it is a pure function of props.

TaskManager (container)
        ├─ useTasks()  ──────────────► localStorage["task-manager:tasks"]
        │     │  tasks, visibleTasks, actions
        │     ▼
        ├─ TaskForm ───── onSubmit(input) ──────► addTask(input)
        ├─ filter/search controls ─────────────► setStatusFilter / setSearchQuery
        └─ TaskList (visibleTasks)
              └─ TaskItem × N ── onToggle/onDelete ──► toggleTask / deleteTask(id)

Important Rules

Keep all task state and mutation logic inside one custom hook — components should only read props and call callbacks, never touch localStorage directly.

Store filter/search *inputs* as state; compute the filtered/searched list with useMemo so it can never go stale.

Always key list items by a stable, unique id (never array index) so React's reconciliation preserves identity correctly.

Wrap all localStorage reads in try/catch — corrupt or missing data must degrade gracefully, not crash the app.

Why this architecture scales

The container/presentational split and the single-hook-owns-state pattern used here are exactly how larger React codebases stay maintainable: when the app later needs to swap localStorage for a real backend, only useTasks' internals change — its return type stays the same, so TaskForm, TaskList, and TaskItem need zero edits. That boundary is what makes incremental migration possible instead of a full rewrite.

const [filteredTasks, setFilteredTasks] = useState<Task[]>([]); // updated manually after every change const visibleTasks = useMemo(() => tasks.filter(...).filter(...), [tasks, statusFilter, searchQuery]);

Manually-synced derived state drifts: forget one call site that updates tasks without also updating filteredTasks, and the UI silently shows stale results. Deriving with useMemo makes that bug impossible.

<TaskItem key={index} task={task}... /> <TaskItem key={task.id} task={task}... />

Index keys break when the list is filtered, sorted, or an item is deleted from the middle — React can reuse the wrong DOM node and mismatch state (e.g. a checkbox 'jumping' to the wrong row).

Why does useTasks compute visibleTasks with useMemo instead of storing it in its own useState? A. useMemo is required by React whenever a value depends on props B. Storing it separately would require manually keeping it in sync with tasks, filters, and search on every change — a source of stale-state bugs that useMemo eliminates by recomputing from source values C. useState cannot hold arrays D. useMemo is faster than useState in all cases

B. Storing it separately would require manually keeping it in sync with tasks, filters, and search on every change — a source of stale-state bugs that useMemo eliminates by recomputing from source values — Deriving filtered/searched data with useMemo guarantees it's always a pure function of current tasks + filter inputs, so it can never fall out of sync the way manually-updated derived state can.

In the TaskItem component, why is key={task.id} preferred over key={index}? A. Because ids are shorter strings and render faster B. Because React requires keys to be UUIDs C. Because a stable id preserves each item's identity across reorders/deletes, while an index key can cause React to reuse the wrong DOM node and mismatch component state D. There is no difference; both work identically in all cases

C. Because a stable id preserves each item's identity across reorders/deletes, while an index key can cause React to reuse the wrong DOM node and mismatch component state — Index-based keys are tied to position, not identity — deleting an item shifts every subsequent index, which can cause React to misattribute local state (like an open edit form) to the wrong task.

Why is renderHook used to test useTasks instead of rendering the full TaskManager component tree? A. renderHook is the only way to call any React hook in a test B. It isolates the hook's logic from rendering/DOM concerns, so tests are faster and only fail when the actual state logic is wrong, not because of an unrelated markup change C. renderHook automatically mocks localStorage D. Full component tests are not supported by @testing-library/react

B. It isolates the hook's logic from rendering/DOM concerns, so tests are faster and only fail when the actual state logic is wrong, not because of an unrelated markup change — Because all business logic lives in the hook, testing it directly with renderHook gives focused, fast unit tests decoupled from how the UI happens to render that state.

Extend the Task Manager beyond what's shown here: add an editTask UI (inline edit mode on TaskItem), a priority filter alongside the status filter, and at least two component-level tests (for TaskForm's validation error and TaskList's empty state). Also add a top-level ErrorBoundary that catches rendering errors in TaskList and shows a friendly fallback instead of a blank screen.

A minimal inline-edit TaskItem toggles a local isEditing flag; while true it renders a small form pre-filled from task, and its submit handler calls onEdit(task.id, { title, description, priority }) before setting isEditing back to false — no changes to useTasks's public shape are required since editTask already accepts a partial patch.

For inline editing, give TaskItem its own local isEditing boolean state — that's a case where component-local state is correct, since no sibling needs to know about it until the edit is committed via editTask.