π οΈ 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 customuseLocalStoragehook - Build a validated, controlled task form using a reusable
useFormhook - 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.
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.
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 devserves the app with no console errors- You see "TaskFlow" in the browser at
localhost:5173 - Editing text in
App.jsxhot-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
TaskProviderholding an emptytasksarray - Dispatching
ACTIONS.ADDfrom a temporary button adds a task with anidand 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
taskpre-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 sureTaskProviderwraps everything. - Edits create a new task instead of updating β check that
TaskFormdispatchesUPDATEwith the originaltask.idin the payload. - Data vanishes on reload β confirm the persistence effect in
TaskContextlists[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.
| Area | Good 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
localStoragewithfetchcalls to an API (e.g. a small Express or JSON-server backend) touches onlyTaskContext. 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
useTaskFilterswith a sort dropdown (by due date, priority, or last updated). - Keyboard shortcuts. A
useKeyboardShortcuthook 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
setTimeoutcleanup inuseEffect. - 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
useReducerstore 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
- react.dev β Learn React (official)
- react.dev β
useReducerreference - Vite β Getting Started guide
- react.dev β Scaling up with reducer and context
π 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.