Skip to main content

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
info

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.

info

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:

  1. Drag-and-drop: reorder tasks
  2. API integration: persist data by integrating with a backend
  3. Testing: add unit tests with Vitest

Try it​

Once the task management app is complete, take on the following advanced challenges.

  1. 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.

  2. 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.

  3. Implement dark mode: using Tailwind CSS's dark: class, add dark mode support. Manage the theme state with Zustand and persist it.

Hint
  1. Add dueDate?: Date to the Task type and add a date input to TaskForm. Determine overdue with new Date() > new Date(task.dueDate) (because a value restored by persist becomes a string, convert it with new Date()).
  2. Extend FilterType, or add a separate categoryFilter state. You can implement the UI based on FilterTabs.
  3. Tailwind v4's dark: follows the OS setting (prefers-color-scheme) by default. To make it class-switching, define @custom-variant dark in CSS and add class="dark" to the html element. Persist the theme with localStorage.
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.

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.