Skip to main content

πŸ› οΈ Weekend Project: React

Everything Module 12 covered β€” hooks, useReducer, Context, custom hooks, and controlled forms β€” now comes together in one real app. Over a weekend you'll build TaskFlow, a persistent task manager, working through five clear milestones so you always know what to build next and how to tell when it's done.

🎯 Learning Objectives

By the end of this project, you will be able to:

  • Scaffold a modern React app with Vite and organize it into a maintainable folder structure
  • Design global state with useReducer + Context and persist it with a custom useLocalStorage hook
  • Build a validated, controlled task form using a reusable useForm hook
  • Derive filtered lists and live statistics efficiently with useMemo
  • Evaluate your own build against a "what good looks like" rubric before calling it done

Estimated Time: 6–10 hours (a weekend)  β€’  Difficulty: Intermediate

Hands-on: This whole lesson is the exercise β€” you ship a working app in five milestones, ticking a checklist at each one.

In This Lesson

What You're Building

TaskFlow is a single-page task manager that runs entirely in the browser and remembers your data between visits. It's small enough to finish in a weekend, but big enough to exercise every React idea from this module at once. By the end you'll be able to add tasks, edit them, delete them, mark them done, filter and search, and watch a live statistics panel update as you work.

πŸ“– The feature checklist

A finished TaskFlow lets a user:

  • Create, edit, and delete tasks (full CRUD)
  • Give each task a title, description, status, priority, and due date
  • Search and filter by text, status, and priority
  • See a live stats panel (totals, by status, overdue count)
  • Have all of it persist across page reloads via localStorage
πŸ’‘ Why a task manager? It's the "to-do app" grown up. CRUD, derived data, forms, and persistent state are the exact ingredients of almost every real product β€” a task manager just makes them visible without hiding behind a login screen or a backend. Nail these patterns here and you'll reuse them for the rest of your career.

⚠️ One deliberate simplification

To keep the weekend focused, TaskFlow has no backend and no accounts β€” data lives in localStorage. That's a real, valid pattern for a personal tool. Swapping localStorage for a real API is a stretch goal at the end, and because our data access is isolated in one hook, that swap touches surprisingly little code.

The Plan & Architecture

Before writing code, get the shape of the app in your head. Data flows one way: a component dispatches an action, the reducer produces the next state, an effect saves that state to localStorage, and the UI re-renders from the new state. There is exactly one source of truth for tasks.

TaskFlow one-way data flow A component dispatches an action to the reducer, the reducer returns new state held in Context, an effect persists it to localStorage, and components read state back to render. Component dispatch(action) Reducer (state, action) β†’ state State (Context) single source of truth localStorage save re-render from state
Figure 1 β€” One-way data flow. Components never mutate state directly; they dispatch actions, and everything else is a consequence of the new state.

The five milestones

We'll build in the order that keeps the app runnable at every step. You can stop after any milestone and still have something that works.

flowchart LR M1[M1 Β· Scaffold] --> M2[M2 Β· State & Persistence] M2 --> M3[M3 Β· Task Form] M3 --> M4[M4 Β· List Β· Filter Β· Stats] M4 --> M5[M5 Β· Polish & Wire Up]

Folder structure

Keep it flat and predictable. Everything lives under src/; each concern gets its own folder.

src/
β”œβ”€β”€ context/
β”‚   └── TaskContext.jsx      # provider + useTasks() hook
β”œβ”€β”€ reducers/
β”‚   └── taskReducer.js       # pure (state, action) => state
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ useLocalStorage.js   # persist any state to localStorage
β”‚   β”œβ”€β”€ useForm.js           # controlled form + validation
β”‚   └── useTaskFilters.js    # search / filter / derived list
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ TaskForm.jsx
β”‚   β”œβ”€β”€ TaskList.jsx
β”‚   β”œβ”€β”€ TaskCard.jsx
β”‚   β”œβ”€β”€ TaskFilters.jsx
β”‚   └── StatsPanel.jsx
β”œβ”€β”€ App.jsx
└── main.jsx                 # Vite entry point

πŸ’‘ Why this structure?

Grouping by kind (hooks, reducers, components) is fine for an app this size β€” you can always find a file from its role. The important discipline is that reducers stay pure and data access lives in hooks, so your components stay small and easy to reason about.

Milestone 1 β€” Scaffold the App

We use Vite, the modern React toolchain. (Create React App is deprecated β€” Vite is faster to start, faster to reload, and what the React docs now recommend for a plain SPA.)

# Create a React app with Vite
npm create vite@latest taskflow -- --template react

cd taskflow
npm install

# One tiny dependency: stable unique IDs for tasks
npm install uuid

# Start the dev server (http://localhost:5173)
npm run dev

Open src/App.jsx, delete the boilerplate, and drop in a placeholder so you can confirm everything runs:

// src/App.jsx
export default function App() {
  return (
    <div className="app">
      <h1>TaskFlow</h1>
      <p>Scaffold is alive. Milestone 1 complete.</p>
    </div>
  );
}

βœ… Milestone 1 checklist

  • npm run dev serves the app with no console errors
  • You see "TaskFlow" in the browser at localhost:5173
  • Editing text in App.jsx hot-reloads instantly
  • The folder skeleton from the plan exists (empty files are fine)

Milestone 2 β€” State & Persistence

This is the backbone. We build three things in order: a persistence hook, a pure reducer, and a Context provider that ties them together.

2.1 β€” The useLocalStorage hook

This hook behaves like useState, but it reads its initial value from localStorage and writes back on every change. Isolating persistence here means nothing else in the app has to know localStorage exists.

// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = window.localStorage.getItem(key);
      return stored ? JSON.parse(stored) : initialValue;
    } catch (error) {
      console.warn(`Could not read "${key}" from localStorage:`, error);
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.warn(`Could not save "${key}" to localStorage:`, error);
    }
  }, [key, value]);

  return [value, setValue];
}

πŸ’‘ Note the lazy initializer

Passing a function to useState(() => ...) means the localStorage read runs once, on mount, instead of on every render. Reading storage is cheap, but this is the correct habit for any expensive initial value.

2.2 β€” The task reducer

A reducer is a pure function: given the current state and an action, it returns the next state. No side effects, no mutation β€” that's what makes it easy to test and reason about. Action types live in a constant so a typo becomes an error instead of a silent no-op.

// src/reducers/taskReducer.js
import { v4 as uuid } from 'uuid';

export const ACTIONS = {
  ADD: 'add_task',
  UPDATE: 'update_task',
  DELETE: 'delete_task',
  TOGGLE: 'toggle_status',
};

export function taskReducer(state, action) {
  switch (action.type) {
    case ACTIONS.ADD: {
      const now = new Date().toISOString();
      const task = {
        id: uuid(),
        createdAt: now,
        updatedAt: now,
        ...action.payload,
      };
      return [...state, task];
    }

    case ACTIONS.UPDATE:
      return state.map((task) =>
        task.id === action.payload.id
          ? { ...task, ...action.payload, updatedAt: new Date().toISOString() }
          : task
      );

    case ACTIONS.DELETE:
      return state.filter((task) => task.id !== action.payload);

    case ACTIONS.TOGGLE:
      return state.map((task) =>
        task.id === action.payload
          ? {
              ...task,
              status: task.status === 'completed' ? 'todo' : 'completed',
              updatedAt: new Date().toISOString(),
            }
          : task
      );

    default:
      return state;
  }
}

⚠️ Never mutate state in a reducer

Always return a new array or object ([...state, task], state.map(...)). Calling state.push() or assigning task.status = ... mutates the existing reference, and React may skip the re-render because the reference didn't change. Every case above builds fresh data on purpose.

2.3 β€” Context that persists

We combine the reducer with persistence using React's useReducer initializer overload: the third argument lazily computes the initial state from localStorage. Then one effect saves the whole list whenever it changes.

// src/context/TaskContext.jsx
import { createContext, useContext, useReducer, useEffect } from 'react';
import { taskReducer } from '../reducers/taskReducer';

const TaskContext = createContext(null);

const STORAGE_KEY = 'taskflow.tasks';

function loadInitial() {
  try {
    const stored = window.localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : [];
  } catch {
    return [];
  }
}

export function TaskProvider({ children }) {
  const [tasks, dispatch] = useReducer(taskReducer, [], loadInitial);

  useEffect(() => {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
  }, [tasks]);

  return (
    <TaskContext.Provider value={{ tasks, dispatch }}>
      {children}
    </TaskContext.Provider>
  );
}

// Custom hook so components never touch the raw context
export function useTasks() {
  const context = useContext(TaskContext);
  if (context === null) {
    throw new Error('useTasks must be used inside a <TaskProvider>');
  }
  return context;
}

Wrap the app so every component can reach the store:

// src/App.jsx
import { TaskProvider } from './context/TaskContext';

export default function App() {
  return (
    <TaskProvider>
      <h1>TaskFlow</h1>
      {/* form, list, and stats go here in later milestones */}
    </TaskProvider>
  );
}

βœ… Milestone 2 checklist

  • In the React DevTools you can see the TaskProvider holding an empty tasks array
  • Dispatching ACTIONS.ADD from a temporary button adds a task with an id and timestamps
  • After adding a task and reloading the page, the task is still there
  • The reducer never mutates β€” every case returns a new array

Milestone 3 β€” The Task Form

Now users need a way to create and edit tasks. We build a reusable useForm hook first, then a TaskForm that uses it. The same form handles both "create" (no task passed) and "edit" (an existing task passed in).

3.1 β€” The useForm hook

This hook owns the form's values, tracks validation errors, and exposes the handlers a controlled form needs. Validation is a function you pass in, so the hook stays generic.

// src/hooks/useForm.js
import { useState, useCallback } from 'react';

export function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  const handleChange = useCallback((event) => {
    const { name, value } = event.target;
    setValues((prev) => ({ ...prev, [name]: value }));
    // Clear a field's error as soon as the user edits it
    setErrors((prev) => {
      if (!prev[name]) return prev;
      const next = { ...prev };
      delete next[name];
      return next;
    });
  }, []);

  const handleSubmit = useCallback(
    (onValid) => (event) => {
      event.preventDefault();
      const validationErrors = validate ? validate(values) : {};
      setErrors(validationErrors);
      if (Object.keys(validationErrors).length === 0) {
        onValid(values);
      }
    },
    [values, validate]
  );

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
  }, [initialValues]);

  return { values, errors, handleChange, handleSubmit, reset };
}

3.2 β€” The TaskForm component

Every input is controlled: its value comes from state, and onChange is the only way it changes. The form validates on submit and dispatches either an ADD or an UPDATE.

// src/components/TaskForm.jsx
import { useTasks } from '../context/TaskContext';
import { useForm } from '../hooks/useForm';
import { ACTIONS } from '../reducers/taskReducer';

const EMPTY = {
  title: '',
  description: '',
  status: 'todo',
  priority: 'medium',
  dueDate: '',
};

function validateTask(values) {
  const errors = {};
  if (!values.title.trim()) errors.title = 'Title is required';
  if (values.title.length > 80) errors.title = 'Keep the title under 80 characters';
  return errors;
}

export default function TaskForm({ task, onDone }) {
  const { dispatch } = useTasks();
  const { values, errors, handleChange, handleSubmit, reset } = useForm(
    task ?? EMPTY,
    validateTask
  );

  const save = (formValues) => {
    if (task) {
      dispatch({ type: ACTIONS.UPDATE, payload: { ...formValues, id: task.id } });
    } else {
      dispatch({ type: ACTIONS.ADD, payload: formValues });
      reset();
    }
    onDone?.();
  };

  return (
    <form className="task-form" onSubmit={handleSubmit(save)}>
      <label>
        Title
        <input
          name="title"
          value={values.title}
          onChange={handleChange}
          aria-invalid={Boolean(errors.title)}
        />
      </label>
      {errors.title && <p className="field-error">{errors.title}</p>}

      <label>
        Description
        <textarea
          name="description"
          rows="3"
          value={values.description}
          onChange={handleChange}
        />
      </label>

      <div className="form-row">
        <label>
          Status
          <select name="status" value={values.status} onChange={handleChange}>
            <option value="todo">To Do</option>
            <option value="in-progress">In Progress</option>
            <option value="completed">Completed</option>
          </select>
        </label>

        <label>
          Priority
          <select name="priority" value={values.priority} onChange={handleChange}>
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
        </label>

        <label>
          Due date
          <input
            type="date"
            name="dueDate"
            value={values.dueDate}
            onChange={handleChange}
          />
        </label>
      </div>

      <button type="submit">{task ? 'Save changes' : 'Add task'}</button>
    </form>
  );
}

βœ… Milestone 3 checklist

  • Submitting with an empty title shows "Title is required" and does not add a task
  • The error clears the moment you start typing a title
  • A valid submit adds a task and resets the form to blank
  • Passing an existing task pre-fills the fields and saves as an update

Milestone 4 β€” List, Filter & Stats

With tasks going in, we need to show them, let the user narrow them down, and summarize them. All three are derived from the single tasks array β€” we never store a second copy.

4.1 β€” The useTaskFilters hook

Filtering is pure computation over the task list, so it belongs in a hook wrapped in useMemo. The filtered result only recomputes when the tasks or the filter criteria actually change.

// src/hooks/useTaskFilters.js
import { useState, useMemo } from 'react';

const EMPTY_FILTERS = { search: '', status: '', priority: '' };

export function useTaskFilters(tasks) {
  const [filters, setFilters] = useState(EMPTY_FILTERS);

  const setFilter = (name, value) =>
    setFilters((prev) => ({ ...prev, [name]: value }));

  const filtered = useMemo(() => {
    const term = filters.search.trim().toLowerCase();
    return tasks.filter((task) => {
      if (filters.status && task.status !== filters.status) return false;
      if (filters.priority && task.priority !== filters.priority) return false;
      if (term && !task.title.toLowerCase().includes(term)) return false;
      return true;
    });
  }, [tasks, filters]);

  return { filters, setFilter, reset: () => setFilters(EMPTY_FILTERS), filtered };
}

4.2 β€” The list, cards, and filter bar

// src/components/TaskCard.jsx
import { useTasks } from '../context/TaskContext';
import { ACTIONS } from '../reducers/taskReducer';

export default function TaskCard({ task, onEdit }) {
  const { dispatch } = useTasks();
  const overdue =
    task.status !== 'completed' &&
    task.dueDate &&
    new Date(task.dueDate) < new Date();

  return (
    <article className={`task-card priority-${task.priority}`}>
      <label className="task-done">
        <input
          type="checkbox"
          checked={task.status === 'completed'}
          onChange={() => dispatch({ type: ACTIONS.TOGGLE, payload: task.id })}
        />
        <span>{task.title}</span>
      </label>

      {task.description && <p>{task.description}</p>}

      <footer>
        <span>{task.priority}</span>
        {task.dueDate && (
          <span className={overdue ? 'overdue' : ''}>due {task.dueDate}</span>
        )}
        <button onClick={() => onEdit(task)}>Edit</button>
        <button
          onClick={() => dispatch({ type: ACTIONS.DELETE, payload: task.id })}
        >
          Delete
        </button>
      </footer>
    </article>
  );
}
// src/components/TaskList.jsx
import { useTasks } from '../context/TaskContext';
import { useTaskFilters } from '../hooks/useTaskFilters';
import TaskCard from './TaskCard';

export default function TaskList({ onEdit }) {
  const { tasks } = useTasks();
  const { filters, setFilter, reset, filtered } = useTaskFilters(tasks);

  if (tasks.length === 0) {
    return <p className="empty">No tasks yet β€” add your first one above.</p>;
  }

  return (
    <section>
      <div className="task-filters">
        <input
          type="search"
          placeholder="Search titles…"
          value={filters.search}
          onChange={(e) => setFilter('search', e.target.value)}
        />
        <select
          value={filters.status}
          onChange={(e) => setFilter('status', e.target.value)}
        >
          <option value="">All statuses</option>
          <option value="todo">To Do</option>
          <option value="in-progress">In Progress</option>
          <option value="completed">Completed</option>
        </select>
        <select
          value={filters.priority}
          onChange={(e) => setFilter('priority', e.target.value)}
        >
          <option value="">All priorities</option>
          <option value="low">Low</option>
          <option value="medium">Medium</option>
          <option value="high">High</option>
        </select>
        <button onClick={reset}>Reset</button>
      </div>

      {filtered.length === 0 ? (
        <p className="empty">No tasks match these filters.</p>
      ) : (
        <div className="task-grid">
          {filtered.map((task) => (
            <TaskCard key={task.id} task={task} onEdit={onEdit} />
          ))}
        </div>
      )}
    </section>
  );
}

4.3 β€” Live statistics

The stats panel is pure derived data β€” one useMemo over the task list. No charting library needed; a few numbers tell the story clearly.

// src/components/StatsPanel.jsx
import { useMemo } from 'react';
import { useTasks } from '../context/TaskContext';

export default function StatsPanel() {
  const { tasks } = useTasks();

  const stats = useMemo(() => {
    const now = new Date();
    return {
      total: tasks.length,
      completed: tasks.filter((t) => t.status === 'completed').length,
      inProgress: tasks.filter((t) => t.status === 'in-progress').length,
      overdue: tasks.filter(
        (t) =>
          t.status !== 'completed' && t.dueDate && new Date(t.dueDate) < now
      ).length,
    };
  }, [tasks]);

  return (
    <dl className="stats-panel">
      <div><dt>Total</dt><dd>{stats.total}</dd></div>
      <div><dt>In progress</dt><dd>{stats.inProgress}</dd></div>
      <div><dt>Completed</dt><dd>{stats.completed}</dd></div>
      <div className={stats.overdue > 0 ? 'alert' : ''}>
        <dt>Overdue</dt><dd>{stats.overdue}</dd>
      </div>
    </dl>
  );
}

βœ… Milestone 4 checklist

  • Tasks render as cards; the checkbox toggles completed status
  • Search + status + priority filters narrow the list together
  • "Reset" clears all filters at once
  • The stats panel updates instantly as you add, complete, or delete tasks
  • An overdue task (past due date, not completed) is counted and visibly flagged

Milestone 5 β€” Polish & Wire Up

Assemble the pieces in App.jsx. The one bit of local UI state left is "which task, if any, am I editing" β€” a perfect job for useState held at the top and passed down.

// src/App.jsx
import { useState } from 'react';
import { TaskProvider } from './context/TaskContext';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import StatsPanel from './components/StatsPanel';

export default function App() {
  const [editing, setEditing] = useState(null);

  return (
    <TaskProvider>
      <main className="app">
        <header>
          <h1>TaskFlow</h1>
          <StatsPanel />
        </header>

        <TaskForm
          key={editing?.id ?? 'new'}
          task={editing}
          onDone={() => setEditing(null)}
        />

        <TaskList onEdit={setEditing} />
      </main>
    </TaskProvider>
  );
}

πŸ’‘ The key={editing?.id ?? 'new'} trick

Changing a component's key tells React to throw the old instance away and mount a fresh one. Here it forces TaskForm to re-initialize its internal useForm state whenever you switch between "add new" and editing a specific task β€” no manual reset wiring needed.

Add a little CSS (any file imported in main.jsx) so it reads as a real app: a responsive task-grid, colored left borders per priority-*, and a muted style for overdue text. Styling is yours to own β€” the checklist below cares about behavior, not pixels.

πŸ’‘ Stuck? Common wiring bugs
  • "useTasks must be used inside a <TaskProvider>" β€” a component using useTasks() is rendered outside the provider. Make sure TaskProvider wraps everything.
  • Edits create a new task instead of updating β€” check that TaskForm dispatches UPDATE with the original task.id in the payload.
  • Data vanishes on reload β€” confirm the persistence effect in TaskContext lists [tasks] as its dependency.
βœ… Reference: what the finished App wiring does

App holds one piece of state β€” editing. When it's null, TaskForm is in "add" mode; when TaskList calls onEdit(task), editing becomes that task and the form re-mounts pre-filled. On save, onDone sets editing back to null. The provider supplies tasks + dispatch to the form, list, cards, and stats alike, so there's exactly one source of truth throughout.

βœ… Milestone 5 checklist β€” the app is done when…

  • You can add, edit, complete, and delete tasks end to end
  • Clicking "Edit" pre-fills the form; saving updates the same card (no duplicate)
  • Filters and stats stay correct through all of the above
  • Everything survives a full page reload
  • The browser console is clean β€” no warnings or errors

What Good Looks Like

Finishing the checklist means it works. This rubric is how you tell whether it's good β€” the difference between a demo and code you'd be happy to show an interviewer. Grade yourself honestly.

AreaGood enough βœ…Needs work ⚠️
State One source of truth; reducer is pure and never mutates Duplicated task lists in local state that drift out of sync
Components Small and single-purpose; each reads only what it needs A giant App.jsx doing form, list, and filtering inline
Custom hooks Persistence, form, and filtering each isolated in a hook localStorage and validation logic scattered across components
Derived data Filters and stats computed with useMemo, not stored A separate filteredTasks state you have to keep updating by hand
Keys & forms Stable key={task.id}; every input controlled key={index}; uncontrolled inputs read via refs
Accessibility Inputs have labels; buttons say what they do Icon-only buttons and placeholder-as-label

⚠️ The most common "works but not good" trap

Storing a second copy of the data β€” e.g. keeping filteredTasks in its own useState and updating it in an effect. Now you have two sources of truth that can disagree. Derived data should be computed from the source on every render (memoized if needed), never stored alongside it. If you catch yourself writing an effect to "keep two states in sync," delete one of them.

Stretch Goals

Finished early, or want to push further? Each of these is a self-contained upgrade that reinforces a real-world skill. Do them in any order.

  • Swap in a real backend. Because all data access lives in the reducer + context, replacing localStorage with fetch calls to an API (e.g. a small Express or JSON-server backend) touches only TaskContext. This is the payoff of isolating persistence.
  • Projects. Add a second entity: tasks belong to a project. A second reducer + context, and a project filter on the list.
  • Sort options. Extend useTaskFilters with a sort dropdown (by due date, priority, or last updated).
  • Keyboard shortcuts. A useKeyboardShortcut hook that focuses the title input when the user presses n.
  • Undo delete. Keep the last deleted task in state and offer a 5-second "Undo" β€” a great excuse to practice setTimeout cleanup in useEffect.
  • Tests. The pure reducer is trivial to unit-test with Vitest β€” write one test per action type.

πŸ’‘ Ship it

TaskFlow is a static SPA, so it deploys free to Netlify, Vercel, or GitHub Pages in minutes (npm run build produces a dist/ folder). Putting a real project on the internet β€” with a link you can share β€” is worth more than another tutorial.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Building in milestones keeps a project runnable and your motivation high β€” each step ends with something that works.
  • One source of truth: tasks live in a useReducer store shared through Context; the reducer is pure and never mutates.
  • Custom hooks (useLocalStorage, useForm, useTaskFilters) isolate persistence, form logic, and filtering so components stay small.
  • Derived data (filtered lists, stats) is computed with useMemo, never stored as a second copy.
  • "Works" is the checklist; "good" is the rubric β€” measure your build against both.

🎯 Quick Quiz

Question 1: Why is the filtered task list computed with useMemo instead of being kept in its own useState?

Question 2: What is wrong with writing state.push(newTask); return state; inside the reducer?

Question 3: This project uses Vite to scaffold the app. Why not Create React App?

πŸ“š Further Reading

πŸš€ What's Next?

TaskFlow shared state through a single Context, which is perfect at this scale. In the next module we go deeper on the Context API itself β€” how to design it well, avoid unnecessary re-renders, and combine multiple contexts as an app grows.

πŸŽ‰ You shipped a real app!

Hooks, reducers, context, custom hooks, and forms β€” all working together in something you built. That's the whole module, made real.