Build a Task Manager with Zustand
Use Zustand to manage global state in a React task manager app.
Introduction
30 Second Summary
Every productivity app you use faces the same challenge: keeping dozens of moving pieces in sync as you check off tasks, add new ones, and switch between views. Behind the scenes, that coordination is called state management.
In this project, you will build a task manager app using Zustand, a lightweight state management library for React. You'll create tasks, mark them complete, filter them by status, and persist everything across browser refreshes.
What You'll Build
A React task manager application powered entirely by Zustand stores, with no Context providers anywhere in the component tree.
By the end of this project, you'll have:
- A typed Zustand store with state and actions for creating, completing, and deleting tasks.
- Filter controls that switch between All, Active, and Completed views using store-driven state.
- Persistent storage via Zustand's persist middleware so tasks survive page refreshes.
- A stats header that uses fine-grained selectors to re-render only when task counts change.
- Secret Mission: Add a dark mode toggle using a separate Zustand store to explore multi-store architecture.
Are there any prerequisites?
This project assumes basic familiarity with React components and hooks like useState and useEffect. You don't need any prior experience with state management libraries.
Set Up Your Development Environment
Every React app needs a foundation. In this step, you will scaffold a complete React + TypeScript project using Vite, a modern build tool that handles bundling and hot module replacement.
By the end of this step, you will have a running development server with a live-reloading React app on your machine.
In this step, get ready to:
- Scaffold a React + TypeScript project with Vite.
- Install project dependencies.
- Verify the development server runs.
Scaffold your React + TypeScript project
Vite provides a scaffold command that generates a complete project structure in seconds. You will use the react-ts template to get React and TypeScript preconfigured.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your task manager project by running this command:
- If npm asks to install a package with the message "Ok to proceed?", type y and press Enter.
npm create vite@latest task-manager -- --template react-ts
What does this command do?
npm create vite@latest runs the create-vite scaffolding tool. The task-manager argument names your project folder.
The -- separator passes options directly to create-vite. The --template react-ts flag tells it to use the React + TypeScript starter template.
- You should see a message confirming the project was scaffolded successfully.
- Move into your new project folder by running:
cd task-manager
Seeing an error after the scaffold command?
- If you see a "command not found" error, confirm Node.js is installed by running node --version. You need version 20.19 or higher.
- If the folder already exists from a previous attempt, delete it with rm -rf ~/Desktop/task-manager and run the scaffold command again.
Help me troubleshoot my Vite scaffold command
- Open the task-manager folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the task-manager folder on your Desktop, then click Open.
Why open the folder in VS Code?
Opening the project folder directly gives VS Code full context of your file structure. This enables features like auto-imports, TypeScript checking, and integrated terminal access scoped to your project.
Install dependencies and start the dev server
The scaffold only created the project files. You still need to download the actual packages (React, TypeScript, Vite itself) before the app can run.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS) or Ctrl+` (Windows).
- Install the project dependencies by running:
npm install
What does npm install do?
This command reads the package.json file in your project and downloads every listed dependency into a node_modules folder. It also creates a package-lock.json file that locks exact versions for consistency.
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:5173.
- You should see the Vite + React starter page with spinning logos and a counter button.
Don't see the starter page?
- Check your terminal for errors. If you see a message about missing modules, run npm install again and then npm run dev.
- Make sure you are inside the task-manager folder when you run the command. Run pwd to check your current directory.
- If port 5173 is already in use, Vite will pick a different port and display it in the terminal output.
Help me troubleshoot my Vite dev server
Keep the dev server running
Leave this terminal running for the rest of the project. The dev server watches your files and reloads the browser automatically whenever you save a change.
Your development environment is ready. Next up, you will install Zustand and create your first store to manage task state.
Install Zustand and Create Your First Store
Your development environment is ready with a running React app. Now it's time to set up the brain of your task manager: a centralized store that holds all task data and the actions to modify it.
Zustand is a lightweight state management library that lets you create stores as plain hooks. There are no providers to wrap around your app and no boilerplate reducers to write.
In this step, get ready to:
- Install Zustand as a project dependency.
- Create a typed store with state and actions.
- Add persistence so tasks survive page refreshes.
Install Zustand
Your dev server is still running in your current terminal. You need a second terminal to run install commands.
- In VS Code, open a new terminal by pressing Ctrl+` (macOS and Windows).
- Install Zustand by running this command:
npm install zustand
- Confirm Zustand was added by running:
cat package.json | grep zustand
You should see zustand listed under dependencies in the output.
Not seeing zustand in dependencies?
Make sure your terminal is inside the task-manager folder. Run pwd to check your current directory.
If you see a network error, check your internet connection and try running the install command again.
Help me troubleshoot why zustand isn't in my dependencies
Create the store file and define your types
Every Zustand store lives in its own file. You will define the shape of your data (interfaces) first, then implement the logic that modifies it.
- In the VS Code Explorer sidebar, right-click the src folder and select New Folder.
- Name the folder store.
- Right-click the new store folder and select New File.
- Name the file taskStore.ts.
- Add the following type definitions to taskStore.ts:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Define the shape of a single task
export interface Task {
id: string
title: string
completed: boolean
}
// Filter options for viewing tasks
export type FilterType = 'all' | 'active' | 'completed'
// Define all state and actions the store will hold
interface TaskState {
tasks: Task[]
filter: FilterType
addTask: (title: string) => void
toggleTask: (id: string) => void
deleteTask: (id: string) => void
setFilter: (filter: FilterType) => void
}
What does this code do?
The Task interface describes what a single task looks like: a unique ID, a title, and a completed flag.
The TaskState interface describes everything the store manages. It includes the data (tasks array and filter) and the actions that can modify that data (addTask, toggleTask, deleteTask, setFilter).
The imports pull in create (to make the store) and persist (to save state to localStorage) from Zustand.
Why Zustand instead of Context or Redux?
React Context re-renders every component in the subtree when any value changes. Redux requires wrapping your entire app in a Provider and writing boilerplate reducers.
Zustand stores are standalone hooks. Components import the hook directly and subscribe only to the slice of state they need. No providers anywhere in your component tree.
You won't see a change in the browser yet. The next chunk builds the actual store logic using these types.
Implement the store actions and persistence
With the types in place, you can now implement the store itself. Zustand's create function turns your state and actions into a React hook that any component can use.
- Add the store implementation below the TaskState interface in taskStore.ts:
export const useTaskStore = create<TaskState>()(
persist(
(set) => ({
// Initial state values
tasks: [],
filter: 'all',
// Action: add a new task with a unique ID
addTask: (title) =>
set((state) => ({
tasks: [
...state.tasks,
{ id: Date.now().toString(), title, completed: false },
],
})),
// Action: flip a task's completed status
toggleTask: (id) =>
set((state) => ({
tasks: state.tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
),
})),
// Action: remove a task by ID
deleteTask: (id) =>
set((state) => ({
tasks: state.tasks.filter((task) => task.id !== id),
})),
// Action: change the active filter
setFilter: (filter) => set({ filter }),
}),
{ name: 'task-storage' }
)
)
What does this code do?
- create<TaskState>()() uses Zustand's curried TypeScript pattern. The extra () is required so TypeScript can properly infer the state type.
- persist(...) wraps the store so all state is automatically saved to localStorage under the key task-storage.
- Each action uses set to produce a new state object. Zustand shallow-merges it with the existing state.
- The spread operator (...state.tasks) ensures immutable updates. The original array is never mutated.
- Add the selector function below useTaskStore in the same file:
// Derived selector: compute which tasks to display based on filter
export const getFilteredTasks = (state: TaskState): Task[] => {
switch (state.filter) {
case 'active':
return state.tasks.filter((task) => !task.completed)
case 'completed':
return state.tasks.filter((task) => task.completed)
default:
return state.tasks
}
}
What is a selector?
A selector is a function that derives a value from your store state. When a component subscribes using a selector, it only re-renders when that specific derived value changes.
getFilteredTasks computes the visible tasks based on the current filter. Components using this selector won't re-render when unrelated state changes.
- Save taskStore.ts.
- Check your terminal running the dev server. You should see no errors in the output.
Seeing a TypeScript error?
If you see a type error mentioning TaskState, make sure the TaskState interface is defined above useTaskStore in the file. TypeScript reads top to bottom.
If you see an error about persist, confirm your import line reads import { persist } from 'zustand/middleware' with the /middleware path.
Help me debug my TypeScript error in taskStore.ts
✔️ Awesome, I've got everything!
Great. Double check you have saved taskStore.ts and your dev server shows no errors.
ⓧ I'd like to double check the full code
Here is the complete src/store/taskStore.ts file:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface Task {
id: string
title: string
completed: boolean
}
export type FilterType = 'all' | 'active' | 'completed'
interface TaskState {
tasks: Task[]
filter: FilterType
addTask: (title: string) => void
toggleTask: (id: string) => void
deleteTask: (id: string) => void
setFilter: (filter: FilterType) => void
}
export const useTaskStore = create<TaskState>()(
persist(
(set) => ({
tasks: [],
filter: 'all',
addTask: (title) =>
set((state) => ({
tasks: [
...state.tasks,
{ id: Date.now().toString(), title, completed: false },
],
})),
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),
})),
setFilter: (filter) => set({ filter }),
}),
{ name: 'task-storage' }
)
)
export const getFilteredTasks = (state: TaskState): Task[] => {
switch (state.filter) {
case 'active':
return state.tasks.filter((task) => !task.completed)
case 'completed':
return state.tasks.filter((task) => task.completed)
default:
return state.tasks
}
}
Your store is ready with typed state, actions, and persistence. Next up, you will build the UI components that connect to this store and bring your task manager to life.
Build the Task Manager UI
Your Zustand store is ready with all the state and actions your task manager needs. Now it's time to build the React components that connect to this store.
In this step, you'll create three components that each import the useTaskStore hook directly. No Context providers, no prop drilling. Each component grabs exactly the slice of state it needs.
In this step, get ready to:
- Create a TaskInput component for adding new tasks.
- Create a TaskItem component for toggling and deleting tasks.
- Create a TaskList component that renders filtered tasks and wire everything into App.tsx.
Create the TaskInput component
The TaskInput component handles adding new tasks. It manages its own input field state with React's useState and calls the store's addTask action when the form is submitted.
- Open your terminal in VS Code by pressing Ctrl+` (macOS) or Ctrl+` (Windows).
- Create a components folder inside src by running this command:
mkdir src/components
- Confirm the folder exists by running:
ls src
You should see components listed in the output alongside store and other existing files.
- Create a new file at src/components/TaskInput.tsx and add the following code:
import { useState } from 'react'
import { useTaskStore } from '../store/taskStore'
export function TaskInput() {
const [title, setTitle] = useState('')
const addTask = useTaskStore((state) => state.addTask)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (title.trim()) {
addTask(title.trim())
setTitle('')
}
}
return (
<form onSubmit={handleSubmit} className="task-input">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>
)
}
What does this code do?
This component uses a selector to grab only the addTask action from the store: useTaskStore((state) => state.addTask). The component only re-renders if addTask itself changes (which it won't). This keeps the input fast regardless of how many tasks exist.
The handleSubmit function trims whitespace, adds the task, and clears the input field. The title.trim() check prevents adding empty tasks.
- Save src/components/TaskInput.tsx.
- You won't see a change in the browser yet. The next chunks wire this component into App.tsx.
Seeing a red underline in VS Code?
If the import path '../store/taskStore' shows an error, check that your taskStore.ts file is saved inside src/store/. The relative path ../store goes up one level from components into src, then into store.
help me fix the import error in TaskInput.tsx
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import { useTaskStore } from '../store/taskStore'
export function TaskInput() {
const [title, setTitle] = useState('')
const addTask = useTaskStore((state) => state.addTask)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (title.trim()) {
addTask(title.trim())
setTitle('')
}
}
return (
<form onSubmit={handleSubmit} className="task-input">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add</button>
</form>
)
}
Create the TaskItem component
Each task in the list needs its own component to handle toggling completion and deletion. TaskItem receives a single task as a prop and pulls the relevant actions from the store.
- Create a new file at src/components/TaskItem.tsx and add the following code:
import { Task, useTaskStore } from '../store/taskStore'
interface TaskItemProps {
task: Task
}
export function TaskItem({ task }: TaskItemProps) {
const toggleTask = useTaskStore((state) => state.toggleTask)
const deleteTask = useTaskStore((state) => state.deleteTask)
return (
<li className={`task-item ${task.completed ? 'completed' : ''}`}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
<span>{task.title}</span>
</label>
<button onClick={() => deleteTask(task.id)} className="delete-btn">
Delete
</button>
</li>
)
}
What does this code do?
TaskItem imports the Task interface from your store so TypeScript knows the shape of the task prop. It uses two separate selectors to grab toggleTask and deleteTask independently.
The className uses a template literal to add the completed class when a task is done. This lets CSS apply strikethrough styling to finished tasks.
- Save src/components/TaskItem.tsx.
- You won't see a change yet. The TaskList component (next chunk) renders TaskItem.
TypeScript error on the Task import?
Make sure your taskStore.ts file uses export interface Task (not just interface Task). The export keyword is what makes it importable from other files.
help me fix the Task import error in TaskItem.tsx
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { Task, useTaskStore } from '../store/taskStore'
interface TaskItemProps {
task: Task
}
export function TaskItem({ task }: TaskItemProps) {
const toggleTask = useTaskStore((state) => state.toggleTask)
const deleteTask = useTaskStore((state) => state.deleteTask)
return (
<li className={`task-item ${task.completed ? 'completed' : ''}`}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
/>
<span>{task.title}</span>
</label>
<button onClick={() => deleteTask(task.id)} className="delete-btn">
Delete
</button>
</li>
)
}
Create the TaskList and wire up App.tsx
TaskList is where your getFilteredTasks selector comes into play. It reads the filtered task array from the store and renders a TaskItem for each one.
- Create a new file at src/components/TaskList.tsx and add the following code:
import { useTaskStore, getFilteredTasks } from '../store/taskStore'
import { TaskItem } from './TaskItem'
export function TaskList() {
const filteredTasks = useTaskStore(getFilteredTasks)
if (filteredTasks.length === 0) {
return <p className="empty-message">No tasks to show.</p>
}
return (
<ul className="task-list">
{filteredTasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
)
}
What does this code do?
Instead of writing a selector inline, TaskList passes the getFilteredTasks function directly to useTaskStore. Zustand calls this function with the full state and returns the derived array. The component only re-renders when the filtered result actually changes.
The empty-state check shows a message when no tasks match the current filter. Each task renders as a TaskItem with a unique key prop so React can track individual items efficiently.
- Save src/components/TaskList.tsx.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { useTaskStore, getFilteredTasks } from '../store/taskStore'
import { TaskItem } from './TaskItem'
export function TaskList() {
const filteredTasks = useTaskStore(getFilteredTasks)
if (filteredTasks.length === 0) {
return <p className="empty-message">No tasks to show.</p>
}
return (
<ul className="task-list">
{filteredTasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
)
}
Now you need to render these components in your app. The default App.tsx from the Vite scaffold has placeholder content that you'll replace entirely.
- Open src/App.tsx and replace its entire contents with the following code:
import { TaskInput } from './components/TaskInput'
import { TaskList } from './components/TaskList'
import './App.css'
function App() {
return (
<div className="app">
<h1>Task Manager</h1>
<TaskInput />
<TaskList />
</div>
)
}
export default App
What does this code do?
This is the entire App component. Notice there are no providers wrapping your component tree. With Zustand, each component imports the store hook directly. No <Provider> wrapper is needed at the app level.
The TaskInput handles creating tasks and TaskList handles displaying them. Both read from the same store independently.
- Save src/App.tsx.
- Start your dev server by running this command in the terminal:
npm run dev
- Open http://localhost:5173 in your browser. You should see a "Task Manager" heading with a text input and an "Add" button. Below that, you should see "No tasks to show." in italics.
- Type a task name into the input and click Add. The task appears in the list with a checkbox and a Delete button.
Seeing a blank page or errors in the console?
Check the terminal for compile errors. The most common issue is a typo in an import path. Make sure TaskList.tsx imports from './TaskItem' (same folder) and App.tsx imports from './components/TaskInput' and './components/TaskList'.
Also verify every file is saved. VS Code shows a dot on unsaved file tabs.
help me debug why my task manager app shows a blank page
✔️ Awesome, I've got everything!
Great. Double check you've saved all four files.
ⓧ I'd like to double check the full code
import { TaskInput } from './components/TaskInput'
import { TaskList } from './components/TaskList'
import './App.css'
function App() {
return (
<div className="app">
<h1>Task Manager</h1>
<TaskInput />
<TaskList />
</div>
)
}
export default App
Your components can now add, display, toggle, and delete tasks. Next up, you'll add filter controls so users can view only active or completed tasks.
Add Filtering and Selectors
Your task manager can create, complete, and delete tasks. But right now, every task shows in one big list regardless of its status.
In this step, you will build a filter bar that lets users switch between viewing All, Active, or Completed tasks. The store already has the filtering logic baked in. Your job is to create the UI that drives it.
In this step, get ready to:
- Create a FilterBar component with selector-driven buttons.
- Wire the filter bar into the app layout.
Create the FilterBar component
The store already has a filter field and a setFilter action from earlier. Now you need a component that reads the current filter and lets the user change it.
What is a selector?
A selector is the function you pass to useTaskStore. It picks out just the slice of state your component needs.
When you write useTaskStore((state) => state.filter), the component only re-renders when filter changes. Unrelated updates (like adding a task) are ignored.
- Create a new file at src/components/FilterBar.tsx and add the following code:
import { useTaskStore, FilterType } from '../store/taskStore'
// Define the three filter options as a typed array
const filters: { label: string; value: FilterType }[] = [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Completed', value: 'completed' },
]
export function FilterBar() {
// Selector: only re-renders when the filter value changes
const currentFilter = useTaskStore((state) => state.filter)
// Selector: grabs the setFilter action from the store
const setFilter = useTaskStore((state) => state.setFilter)
return (
<div className="filter-bar">
{filters.map(({ label, value }) => (
<button
key={value}
className={currentFilter === value ? 'active' : ''}
onClick={() => setFilter(value)}
>
{label}
</button>
))}
</div>
)
}
What does this code do?
- The filters array defines the three options with display labels and their matching store values.
- Two selectors pull only what the component needs: the current filter string and the setFilter action.
- Each button gets an active class when it matches the current filter, so the CSS can highlight it.
- Clicking a button calls setFilter(value) which updates the store. TaskList already uses getFilteredTasks to react to this change automatically.
- Save FilterBar.tsx.
Seeing a type error?
- Make sure your import includes both useTaskStore and FilterType from ../store/taskStore.
- Check that FilterType is exported from your store file (it should be if you followed the earlier step).
Why is FilterBar not compiling?
Add FilterBar to the app layout
The FilterBar component exists but nothing renders it yet. You need to import it in App.tsx and place it between the input form and the task list.
- In src/App.tsx, find this import line:
import { TaskList } from './components/TaskList'
- Add this new import directly below it:
import { FilterBar } from './components/FilterBar'
- Now find <TaskInput /> in the JSX return and add <FilterBar /> on the line directly below it, so the component tree looks like this:
<TaskInput />
<FilterBar />
<TaskList />
- Save App.tsx.
- Switch to your browser. You should see three filter buttons (All, Active, Completed) appear between the input field and the task list.
- Add a couple of tasks, mark one as completed, then click the Active filter button.
- Only uncompleted tasks should remain visible.
- Click Completed to see only finished tasks, then All to see everything again.
Why does TaskList update automatically?
TaskList uses the getFilteredTasks selector, which derives its output from both state.tasks and state.filter. When FilterBar calls setFilter, the store updates, and Zustand automatically re-runs the selector. Because the derived array changes, TaskList re-renders with the correct subset.
Filter buttons not working?
- Make sure <FilterBar /> is inside the <div className="app"> wrapper, not outside it.
- Verify you saved both FilterBar.tsx and App.tsx. Unsaved changes in either file will prevent the filter from appearing.
- Check that TaskList.tsx imports getFilteredTasks (not state.tasks directly). If it reads all tasks without the filter, clicking the buttons will have no visible effect.
Why are my filter buttons not filtering the task list?
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is src/components/FilterBar.tsx:
import { useTaskStore, FilterType } from '../store/taskStore'
const filters: { label: string; value: FilterType }[] = [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Completed', value: 'completed' },
]
export function FilterBar() {
const currentFilter = useTaskStore((state) => state.filter)
const setFilter = useTaskStore((state) => state.setFilter)
return (
<div className="filter-bar">
{filters.map(({ label, value }) => (
<button
key={value}
className={currentFilter === value ? 'active' : ''}
onClick={() => setFilter(value)}
>
{label}
</button>
))}
</div>
)
}
Here is src/App.tsx:
import { TaskInput } from './components/TaskInput'
import { TaskList } from './components/TaskList'
import { FilterBar } from './components/FilterBar'
import './App.css'
function App() {
return (
<div className="app">
<h1>Task Manager</h1>
<TaskInput />
<FilterBar />
<TaskList />
</div>
)
}
export default App
Your tasks are now filterable with a single click. Next up, you will add persistence so tasks survive a full page refresh.
Add a Task Stats Header
Your tasks now survive page refreshes thanks to the persist middleware. The next question is: how do you display summary information without causing the entire app to re-render every time any piece of state changes?
In this step, you'll create a stats header that shows live task counts. The key technique is using fine-grained selectors to subscribe each counter to only the specific slice of state it needs.
In this step, get ready to:
- Create a TaskStats component with fine-grained selectors.
- Wire TaskStats into the app header with full styling.
- Observe how selectors prevent unnecessary re-renders.
Create the TaskStats component
When you call useTaskStore with a selector function, the component only re-renders when that specific derived value changes. Without a selector, the component would re-render on every state update.
- Create a new file src/components/TaskStats.tsx and add the following code:
import { useTaskStore } from '../store/taskStore'
export function TaskStats() {
// Each selector subscribes to only one derived value
const total = useTaskStore((state) => state.tasks.length)
const completed = useTaskStore(
(state) => state.tasks.filter((t) => t.completed).length
)
const active = useTaskStore(
(state) => state.tasks.filter((t) => !t.completed).length
)
return (
<div className="task-stats">
<span>Total: {total}</span>
<span>Active: {active}</span>
<span>Completed: {completed}</span>
</div>
)
}
What does this code do?
This component calls the store hook three separate times, each with its own selector function.
- state.tasks.length returns the total count of all tasks.
- The completed selector filters for tasks where t.completed is true, then returns that array's length.
- The active selector does the opposite, filtering for tasks where !t.completed.
Because each selector returns a number (not an object), Zustand's strict equality check can detect changes precisely. The component only re-renders when one of these counts actually changes.
- Save TaskStats.tsx.
You won't see a change yet. The next chunk wires this component into the app.
Wire TaskStats into the app header
Now you need to import and render the new component in App.tsx so it appears above the task input.
- In src/App.tsx, find the import lines at the top of the file.
- Find this line:
import { FilterBar } from './components/FilterBar'
import './App.css'
- Add the TaskStats import between these two lines, so the imports now look like this:
import { FilterBar } from './components/FilterBar'
import { TaskStats } from './components/TaskStats'
import './App.css'
- Next, find this line inside the return statement:
<h1>Task Manager</h1>
<TaskInput />
- Add <TaskStats /> between the heading and TaskInput, so it looks like this:
<h1>Task Manager</h1>
<TaskStats />
<TaskInput />
- Save App.tsx.
Seeing a compile error?
- Check that the import path is './components/TaskStats' with a capital T in TaskStats.
- Make sure you saved TaskStats.tsx in the previous substep before saving App.tsx.
Help me fix my TaskStats import error.
Style the complete app
Your components are all wired up, but they need proper styling to look polished. The .task-stats class will style the new stats header as a centered row of counts.
- Open src/App.css and replace its entire contents with the following styles:
.app {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
margin-bottom: 1rem;
}
.task-stats {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #666;
}
.task-input {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.task-input input {
flex: 1;
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.task-input button {
padding: 0.5rem 1.5rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.task-input button:hover {
background: #4338ca;
}
.filter-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filter-bar button {
padding: 0.4rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.filter-bar button.active {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.task-list {
list-style: none;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.task-item label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.task-item.completed span {
text-decoration: line-through;
color: #999;
}
.delete-btn {
padding: 0.25rem 0.75rem;
background: #ef4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.delete-btn:hover {
background: #dc2626;
}
.empty-message {
text-align: center;
color: #999;
font-style: italic;
}
What do these styles do?
- .task-stats creates a centered flexbox row with spacing between the Total, Active, and Completed counters.
- .task-input and .filter-bar use flexbox to lay out the input field and filter buttons in horizontal rows.
- .task-item.completed span adds a strikethrough to completed task titles.
- .delete-btn gives the delete button a red background that darkens on hover.
- Save App.css and check your browser.
- You should see a centered stats bar showing Total: 0, Active: 0, and Completed: 0 below the heading.
- Add a task using the input field. The stats should update to show Total: 1 and Active: 1 immediately.
Stats not updating?
- Make sure <TaskStats /> is placed between the <h1> and <TaskInput /> in App.tsx.
- Confirm the import path uses a capital T: './components/TaskStats'.
- Check that the selector functions in TaskStats.tsx reference state.tasks (not state.task).
Help me debug why my task stats are stuck at zero.
Observe selective re-rendering in action
The TaskStats component demonstrates one of Zustand's biggest advantages: components only re-render when the specific value they subscribe to changes.
- Try switching between the All, Active, and Completed filter buttons.
- Notice that the stats bar does not flash or update when you change filters.
Why doesn't TaskStats re-render on filter changes?
When you click a filter button, the store's filter field changes. But TaskStats never subscribes to state.filter. Its selectors only read from state.tasks.
Zustand uses strict equality (old === new) to decide if a selector's output changed. Since the task counts stay the same when you toggle filters, TaskStats skips the re-render entirely.
- Now toggle a task's checkbox to mark it as completed.
- The stats update instantly because the active and completed counts changed.
Why three selectors instead of one?
You could write a single selector returning an object like { total, active, completed }. But objects create a new reference on every state change, which would cause the component to re-render every time any state updates.
By returning primitive numbers from separate selectors, Zustand's strict equality check works perfectly. The component only re-renders when a count value actually changes.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files: TaskStats.tsx, App.tsx, and App.css.
ⓧ I'd like to double check the full code
Here is the full code for each file modified in this step.
import { useTaskStore } from '../store/taskStore'
export function TaskStats() {
const total = useTaskStore((state) => state.tasks.length)
const completed = useTaskStore(
(state) => state.tasks.filter((t) => t.completed).length
)
const active = useTaskStore(
(state) => state.tasks.filter((t) => !t.completed).length
)
return (
<div className="task-stats">
<span>Total: {total}</span>
<span>Active: {active}</span>
<span>Completed: {completed}</span>
</div>
)
}
import { TaskInput } from './components/TaskInput'
import { TaskList } from './components/TaskList'
import { FilterBar } from './components/FilterBar'
import { TaskStats } from './components/TaskStats'
import './App.css'
function App() {
return (
<div className="app">
<h1>Task Manager</h1>
<TaskStats />
<TaskInput />
<FilterBar />
<TaskList />
</div>
)
}
export default App
.app {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
margin-bottom: 1rem;
}
.task-stats {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #666;
}
.task-input {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.task-input input {
flex: 1;
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.task-input button {
padding: 0.5rem 1.5rem;
background: #4f46e5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.task-input button:hover {
background: #4338ca;
}
.filter-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filter-bar button {
padding: 0.4rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.filter-bar button.active {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.task-list {
list-style: none;
padding: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid #eee;
}
.task-item label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.task-item.completed span {
text-decoration: line-through;
color: #999;
}
.delete-btn {
padding: 0.25rem 0.75rem;
background: #ef4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.delete-btn:hover {
background: #dc2626;
}
.empty-message {
text-align: center;
color: #999;
font-style: italic;
}
Your task manager now has a live stats header that updates efficiently. You have a complete, styled application with persistent state, filtering, and optimized re-rendering. Next up, the Secret Mission challenges you to add dark mode using a separate Zustand store.
Secret mission
Multi-Store Dark Mode
Your task manager works great, but what about late-night productivity sessions? Add a dark mode toggle powered by a completely separate Zustand store. This demonstrates multi-store architecture where each store manages its own slice of state independently.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The task-manager project folder on your Desktop.
- localStorage entries (task-storage and theme-storage) saved by Zustand's persist middleware.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with Zustand patterns.
- Your dev server continues running at localhost:5173.
- Your tasks and theme preference remain in localStorage.
- All project files stay in the task-manager folder on your Desktop.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your project files and localStorage data stay intact for next time.
- Switch to the terminal running the dev server.
- Press Ctrl+C to stop the Vite dev server.
- Close VS Code when you're done.
- To resume later, open the task-manager folder in VS Code and run npm run dev in the terminal.
Delete - I don't want to use this again
Remove all project files and stored data to start completely fresh.
- Stop the dev server by pressing Ctrl+C in the terminal.
- Close VS Code.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/task-manager
Windows
rmdir /s /q %USERPROFILE%\Desktop\task-manager
- Clear the localStorage entries by opening your browser's DevTools (F12), navigating to the Application tab, expanding Local Storage, selecting http://localhost:5173, and deleting the task-storage and theme-storage entries.
Nice Work!
Nice Work!
You just built a fully functional task manager with professional-grade state management. Your app manages tasks, filters, and persistent data using Zustand stores with zero Context providers in the component tree.
You've learned how to:
- Built a typed Zustand store with state and actions that manage tasks without any provider boilerplate.
- Used selectors to optimize component re-rendering so components only update when their specific slice of state changes.
- Added persistent storage with the persist middleware so tasks survive full page refreshes.
- Created a multi-store architecture with independent persistence for dark mode and task data.
Ready to quiz yourself?