Practical project
In this chapter, you leverage the knowledge you have learned so far to build a practical task management app.
What you learn in this chapter
- Building a practical app with React + TypeScript + Tailwind CSS
- State management and local storage persistence with Zustand
- Routing implementation with React Router
- Best practices for component design and feature splitting
In this chapter, you build a task management app based on the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.
Actually building an app hands-on helps the knowledge stick. In this project, you use a bit of everything covered in this book — React / TypeScript / Tailwind CSS / Zustand / React Router. While copying the code, add your own twists according to your interests.
Project overview
Features
- Add, edit, and delete tasks
- Toggle a task's completion state
- Filtering (all / active / completed)
- Persistence to local storage
Technologies used
- React + TypeScript
- Vite
- Tailwind CSS
- Zustand (state management)
- React Router (routing)
Project structure
src/
├── components/
│ ├── TaskForm.tsx
│ ├── TaskItem.tsx
│ ├── TaskList.tsx
│ └── FilterTabs.tsx
├── hooks/
│ └── useLocalStorage.ts
├── stores/
│ └── taskStore.ts
├── types/
│ └── task.ts
├── pages/
│ ├── Home.tsx
│ └── About.tsx
├── App.tsx
└── main.tsx
Type definitions
// src/types/task.ts
export type Task = {
id: string
title: string
completed: boolean
createdAt: Date
}
export type FilterType = 'all' | 'active' | 'completed'
The Zustand store
// src/stores/taskStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Task, FilterType } from '../types/task'
type TaskStore = {
tasks: Task[]
filter: FilterType
addTask: (title: string) => void
toggleTask: (id: string) => void
deleteTask: (id: string) => void
editTask: (id: string, title: string) => void
setFilter: (filter: FilterType) => void
clearCompleted: () => void
}
export const useTaskStore = create<TaskStore>()(
persist(
(set) => ({
tasks: [],
filter: 'all',
addTask: (title) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date()
}
]
})),
toggleTask: (id) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
})),
deleteTask: (id) =>
set((state) => ({
tasks: state.tasks.filter((task) => task.id !== id)
})),
editTask: (id, title) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, title } : task
)
})),
setFilter: (filter) => set({ filter }),
clearCompleted: () =>
set((state) => ({
tasks: state.tasks.filter((task) => !task.completed)
}))
}),
{
name: 'task-storage'
}
)
)
Components
TaskForm
// src/components/TaskForm.tsx
import { useState } from 'react'
import { useTaskStore } from '../stores/taskStore'
export function TaskForm() {
const [title, setTitle] = useState('')
const addTask = useTaskStore((state) => state.addTask)
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (title.trim()) {
addTask(title.trim())
setTitle('')
}
}
return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={title}
onChange={handleTitleChange}
placeholder="Enter a new task..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-lg
hover:bg-blue-600 transition-colors"
>
Add
</button>
</form>
)
}
TaskItem
// src/components/TaskItem.tsx
import { useState } from 'react'
import { useTaskStore } from '../stores/taskStore'
import type { Task } from '../types/task'
type TaskItemProps = {
task: Task
}
export function TaskItem({ task }: TaskItemProps) {
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
const toggleTask = useTaskStore((state) => state.toggleTask)
const deleteTask = useTaskStore((state) => state.deleteTask)
const editTask = useTaskStore((state) => state.editTask)
const handleToggle = () => {
toggleTask(task.id)
}
const handleEditTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEditTitle(e.target.value)
}
const handleStartEditing = () => {
setIsEditing(true)
}
const handleDelete = () => {
deleteTask(task.id)
}
const handleSave = () => {
if (editTitle.trim()) {
editTask(task.id, editTitle.trim())
setIsEditing(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setEditTitle(task.title)
setIsEditing(false)
}
}
return (
<li className="flex items-center gap-3 p-3 bg-white rounded-lg shadow">
<input
type="checkbox"
checked={task.completed}
onChange={handleToggle}
className="w-5 h-5 accent-blue-500"
/>
{isEditing ? (
<input
type="text"
value={editTitle}
onChange={handleEditTitleChange}
onBlur={handleSave}
onKeyDown={handleKeyDown}
autoFocus
className="flex-1 px-2 py-1 border rounded"
/>
) : (
<span
onDoubleClick={handleStartEditing}
className={`flex-1 ${
task.completed ? 'line-through text-gray-400' : ''
}`}
>
{task.title}
</span>
)}
<button
onClick={handleDelete}
className="px-2 py-1 text-red-500 hover:text-red-700"
>
Delete
</button>
</li>
)
}
FilterTabs
// src/components/FilterTabs.tsx
import { useTaskStore } from '../stores/taskStore'
import type { FilterType } from '../types/task'
const filters: { value: FilterType; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'completed', label: 'Completed' }
]
export function FilterTabs() {
const filter = useTaskStore((state) => state.filter)
const setFilter = useTaskStore((state) => state.setFilter)
const createFilterHandler = (value: FilterType) => () => {
setFilter(value)
}
return (
<div className="flex gap-2 mb-4">
{filters.map(({ value, label }) => (
<button
key={value}
onClick={createFilterHandler(value)}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === value
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{label}
</button>
))}
</div>
)
}
TaskList
// src/components/TaskList.tsx
import { useTaskStore } from '../stores/taskStore'
import { TaskItem } from './TaskItem'
export function TaskList() {
const tasks = useTaskStore((state) => state.tasks)
const filter = useTaskStore((state) => state.filter)
const filteredTasks = tasks.filter((task) => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})
if (filteredTasks.length === 0) {
return (
<p className="text-center text-gray-500 py-8">
No tasks
</p>
)
}
return (
<ul className="space-y-2">
{filteredTasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
)
}
Pages
// src/pages/Home.tsx
import { TaskForm } from '../components/TaskForm'
import { FilterTabs } from '../components/FilterTabs'
import { TaskList } from '../components/TaskList'
import { useTaskStore } from '../stores/taskStore'
export function Home() {
const tasks = useTaskStore((state) => state.tasks)
const clearCompleted = useTaskStore((state) => state.clearCompleted)
const completedCount = tasks.filter((t) => t.completed).length
const activeCount = tasks.length - completedCount
const handleClearCompleted = () => {
clearCompleted()
}
return (
<div className="max-w-lg mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">Task Manager</h1>
<TaskForm />
<FilterTabs />
<TaskList />
<div className="mt-4 flex justify-between text-sm text-gray-500">
<span>Remaining: {activeCount}</span>
{completedCount > 0 && (
<button
onClick={handleClearCompleted}
className="hover:text-red-500"
>
Clear completed
</button>
)}
</div>
</div>
)
}
// src/pages/About.tsx
export function About() {
return (
<div className="max-w-lg mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">About</h1>
<p>A task management app built with React + TypeScript + Zustand + React Router.</p>
</div>
)
}
The application
// src/App.tsx
import { Routes, Route, Link } from 'react-router'
import { Home } from './pages/Home'
import { About } from './pages/About'
function App() {
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow p-4 mb-4">
<div className="max-w-lg mx-auto flex gap-4">
<Link to="/" className="text-blue-500 hover:underline">
Home
</Link>
<Link to="/about" className="text-blue-500 hover:underline">
About
</Link>
</div>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</div>
)
}
export default App
// src/main.tsx (revert to the declarative routing setup from Chapter 21)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)
Advanced challenges
Ideas to further extend this project:
- Adding due dates: set a due date on tasks and warn about overdue ones
- Categories/tags: classify and manage tasks
- Drag-and-drop: reorder tasks
- Dark mode: a theme switching feature
- API integration: persist data by integrating with a backend
- Testing: add unit tests with Vitest
Try it
Once the task management app is complete, take on the following advanced challenges.
-
Add a due date feature: make it possible to set a due date on tasks, and display overdue tasks in red. Add a Date type field and add a date input to the form.
-
Add a categories/tags feature: set a category on tasks and make it possible to filter by category. Add a category array to the Zustand store.
-
Implement dark mode: using Tailwind CSS's
dark:class, add dark mode support. Manage the theme state with Zustand and persist it.
Hint
- Add
dueDate?: Dateto theTasktype and add a date input toTaskForm. Determine overdue withnew Date() > new Date(task.dueDate)(because a value restored by persist becomes a string, convert it withnew Date()). - Extend
FilterType, or add a separatecategoryFilterstate. You can implement the UI based onFilterTabs. - Tailwind v4's
dark:follows the OS setting (prefers-color-scheme) by default. To make it class-switching, define@custom-variant darkin CSS and addclass="dark"to thehtmlelement. Persist the theme withlocalStorage.
Answer and explanation
Challenge 1: Add a due date feature
// src/types/task.ts
export type Task = {
id: string
title: string
completed: boolean
createdAt: Date
dueDate?: Date // add a due date
}
// src/stores/taskStore.ts (modify the TaskStore type and addTask)
type TaskStore = {
// ...
addTask: (title: string, dueDate?: Date) => void
// ...
}
addTask: (title, dueDate) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date(),
dueDate
}
]
})),
// src/components/TaskForm.tsx
import { useState } from 'react'
import { useTaskStore } from '../stores/taskStore'
export function TaskForm() {
const [title, setTitle] = useState('')
const [dueDate, setDueDate] = useState('')
const addTask = useTaskStore((state) => state.addTask)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (title.trim()) {
addTask(title.trim(), dueDate ? new Date(dueDate) : undefined)
setTitle('')
setDueDate('')
}
}
return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter a new task..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="px-4 py-2 border rounded-lg"
/>
<button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded-lg">
Add
</button>
</form>
)
}
// src/components/TaskItem.tsx (add the overdue display)
const isOverdue = task.dueDate && new Date() > new Date(task.dueDate) && !task.completed
return (
<li className={`flex items-center gap-3 p-3 rounded-lg shadow ${isOverdue ? 'bg-red-50 border-red-200' : 'bg-white'}`}>
{/* ... */}
{task.dueDate && (
<span className={`text-sm ${isOverdue ? 'text-red-500 font-bold' : 'text-gray-500'}`}>
{new Date(task.dueDate).toLocaleDateString()}
</span>
)}
</li>
)
Tasks that pass their due date have a red background and the date displayed in red. Completed tasks do not show the overdue display. Note that because persist saves as JSON, a Date is restored as a string after reload. Convert it back with new Date() where you use it, or design it to hold an ISO string (string type) from the start.
Challenge 2: Add a categories/tags feature
// src/types/task.ts
export type Task = {
id: string
title: string
completed: boolean
createdAt: Date
category?: string // add a category
}
export type FilterType = 'all' | 'active' | 'completed'
// src/stores/taskStore.ts
type TaskStore = {
tasks: Task[]
filter: FilterType
categoryFilter: string | null // add a category filter
categories: string[] // the category list
addTask: (title: string, category?: string) => void
setCategoryFilter: (category: string | null) => void
// ...
}
export const useTaskStore = create<TaskStore>()(
persist(
(set) => ({
tasks: [],
filter: 'all',
categoryFilter: null,
categories: ['Work', 'Personal', 'Shopping'],
addTask: (title, category) =>
set((state) => ({
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date(),
category
}
]
})),
setCategoryFilter: (category) => set({ categoryFilter: category }),
// ...
}),
{ name: 'task-storage' }
)
)
// src/components/CategoryTabs.tsx
import { useTaskStore } from '../stores/taskStore'
export function CategoryTabs() {
const categories = useTaskStore((state) => state.categories)
const categoryFilter = useTaskStore((state) => state.categoryFilter)
const setCategoryFilter = useTaskStore((state) => state.setCategoryFilter)
const handleClearFilter = () => {
setCategoryFilter(null)
}
const createCategoryHandler = (category: string) => () => {
setCategoryFilter(category)
}
return (
<div className="flex gap-2 mb-4 flex-wrap">
<button
onClick={handleClearFilter}
className={`px-3 py-1 rounded ${
categoryFilter === null ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
All
</button>
{categories.map((category) => (
<button
key={category}
onClick={createCategoryHandler(category)}
className={`px-3 py-1 rounded ${
categoryFilter === category ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
{category}
</button>
))}
</div>
)
}
Challenge 3: Implement dark mode
// src/stores/themeStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type ThemeStore = {
isDark: boolean
toggleTheme: () => void
}
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
isDark: false,
toggleTheme: () => set((state) => ({ isDark: !state.isDark }))
}),
{ name: 'theme-storage' }
)
)
// src/App.tsx
import { useEffect } from 'react'
import { useThemeStore } from './stores/themeStore'
function App() {
const isDark = useThemeStore((state) => state.isDark)
const toggleTheme = useThemeStore((state) => state.toggleTheme)
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark)
}, [isDark])
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 transition-colors">
<nav className="bg-white dark:bg-gray-800 shadow p-4 mb-4">
<div className="max-w-lg mx-auto flex justify-between items-center">
<span className="font-bold dark:text-white">Task Manager</span>
<button
onClick={toggleTheme}
className="p-2 rounded bg-gray-200 dark:bg-gray-700 dark:text-white"
>
{isDark ? '🌞' : '🌙'}
</button>
</div>
</nav>
{/* ... */}
</div>
)
}
/* src/index.css (override the dark variant to be class-switching) */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
When you override the dark variant to be class-linked with @custom-variant, the dark: styles are applied when class="dark" is on the html element. With useEffect, you watch for changes to isDark and toggle the class on document.documentElement. The theme setting is also persisted with the persist middleware.
Summary
Through this book, you have acquired the following modern React development skills.
- React fundamentals: components, Props, State, events
- Hooks: useState, useEffect, custom hooks
- TypeScript: type-safe development
- Tailwind CSS: utility-first styling
- Zustand: simple state management
- TanStack Query: server-state management
- React Router: client-side routing
Building on this knowledge, in the next chapter you will step up to full-stack development.
In the next chapter, you will learn how to implement SPA authentication.
What to read next
We move on to full-stack development. With SPA authentication, the NestJS + Prisma backend, and a full-stack project, you integrate the frontend and backend.