Build a Task Manager with React
Build an interactive Task Manager app using React components, state, and hooks.
Introduction
30 Second Summary
Everyone has a system for tracking their to-do list. Whether it's a sticky note, a phone app, or a spreadsheet, managing tasks is something you already do every day.
In this project, you will build your own Task Manager app from scratch using React. You'll learn the core building blocks of every React application: components, state, props, and event handling.
What You'll Build
A fully interactive Task Manager that runs in the browser, built with reusable React components and local data persistence.
By the end of this project, you'll have:
- A working React app scaffolded with Vite and running locally with instant hot reload.
- Reusable components (TaskForm, TaskList, TaskItem, FilterBar) composed together with props and state.
- Event handling and localStorage persistence so your tasks survive page refreshes.
- Secret Mission: Add an inline editing feature and a task counter showing active and completed stats.
Are there any prerequisites?
You'll need basic knowledge of HTML, CSS, and JavaScript (including DOM manipulation and event handling). Node.js and npm should already be installed on your machine.
Set Up Your React Project with Vite
To build the Task Manager app, you need a working React development environment that gives you instant feedback as you write code.
Vite is a modern build tool that sets up a React project in seconds and reloads the browser every time you save a file. In this step, you'll scaffold your project and clean up the starter files so you have a blank canvas to work with.
In this step, get ready to:
- Scaffold a React project using Vite.
- Start the development server and verify hot reload.
- Clean up starter files so you have a blank canvas.
Scaffold the project using Vite's React template
Vite provides a scaffold command that generates an entire React project structure for you. You'll end up with a ready-to-run app in a folder called task-manager.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Open the terminal inside Cursor by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your React project by running this command:
npm create vite@latest task-manager -- --template react
What will this command ask?
This command triggers a few interactive prompts in your terminal. Here is how to respond:
- npm may ask to install the create-vite package. Enter y to proceed.
- Select No for the rolldown-vite experimental prompt.
- Select No for the "Install with npm and start now?" prompt. You will install dependencies manually in the next command.
- Move into your new project folder and install dependencies by running these commands:
cd task-manager
npm install
- Confirm the project was created by running:
ls
- You should see files like index.html, package.json, vite.config.js, and a src/ folder in the output.
Seeing a framework selection prompt on Windows?
PowerShell treats -- differently. If the command asks you to select a framework instead of using the React template, try replacing -- with --- (three dashes) in the scaffold command.
help me fix the Vite scaffold command in PowerShell
Start the development server
With dependencies installed, you can now start Vite's development server. This gives you a live preview of your app that updates instantly whenever you save a file.
- Start the development server by running:
npm run dev
- Look for a URL in the terminal output (usually http://localhost:5173).
- Hold Cmd (macOS) or Ctrl (Windows) and click the URL to open it in your browser.
You should see the Vite + React starter page with a counter button.
What is hot module replacement?
Vite watches your source files for changes. When you save a file, Vite pushes the update to the browser without a full page refresh. This means you see your changes instantly, and your app's state is preserved.
Don't see the starter page?
Make sure npm install completed without errors before running npm run dev. Check your terminal for any red error text.
If the URL doesn't load, confirm no other process is using port 5173. Vite will show a different port number in the terminal if 5173 is taken.
help me get the Vite dev server running
Clean up the starter files
The Vite template comes with demo code you do not need. You will replace it with a minimal component so you have a clean starting point for your Task Manager.
- In the Explorer sidebar in Cursor, expand the src/ folder inside task-manager.
- Click on App.jsx to open it.
- Select all the existing code (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
- Replace it with this code:
function App() {
return <h1>Task Manager</h1>
}
export default App
What does this code do?
This defines a React component called App that returns a single heading element. The export default App line makes it available for main.jsx to import and render.
The HTML-like syntax inside the return statement is called JSX. React uses JSX to describe what the UI should look like.
- Now delete the starter CSS file. Right-click App.css in the Explorer sidebar and select Delete.
- Save App.jsx with Cmd+S (macOS) or Ctrl+S (Windows).
Switch to your browser. Thanks to hot reload, you should now see only the text "Task Manager" on a blank page.
Still seeing the old starter page?
Make sure you saved App.jsx after replacing its content. Check for a dot on the file tab in Cursor, which indicates unsaved changes.
If you see a console error in your browser, confirm you deleted all the old import lines (like import './App.css') when you replaced the file content. Your App.jsx should contain only the code shown above.
help me fix my App.jsx cleanup
✔️ Awesome, I've got everything!
Great. Make sure you have saved App.jsx and confirmed your browser shows "Task Manager" before moving on.
ⓧ I'd like to double check the full code
Here is what src/App.jsx should look like at this point:
function App() {
return <h1>Task Manager</h1>
}
export default App
And here is src/main.jsx (this file was generated by Vite and should remain unchanged):
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
Your React project is scaffolded and running. Next up, you'll build your first custom component to let users add tasks.
Build the TaskForm Component
Your React project is running and displaying a heading. Now it's time to build your first custom component.
In React, each piece of UI lives in its own component. You'll create a TaskForm component that handles user input, then wire it into App.jsx where your application state lives.
In this step, get ready to:
- Build a TaskForm component with a controlled input and submit handler.
- Create application state in App.jsx to track tasks.
- Verify tasks are being counted when you submit the form.
Create the TaskForm component
A controlled input in React means the component's state is the single source of truth for the input's value. Every keystroke updates state, and the input always reflects that state.
- Create a components folder inside your src folder by running this command in your terminal:
mkdir src/components
- Create a new file called TaskForm.jsx inside src/components/ and add the following code:
import { useState } from 'react'
function TaskForm({ onAddTask }) {
// Track the current text in the input field
const [text, setText] = useState('')
function handleSubmit(e) {
// Prevent the page from refreshing on form submit
e.preventDefault()
// Ignore empty submissions
if (text.trim() === '') return
// Call the parent's handler with the trimmed text
onAddTask(text.trim())
// Clear the input for the next task
setText('')
}
return (
<form className="task-form" onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add Task</button>
</form>
)
}
export default TaskForm
What does this code do?
- { onAddTask } destructures the props object so this component can call a function passed in by its parent.
- useState('') creates a state variable text that starts as an empty string. Every keystroke calls setText to update it.
- e.preventDefault() stops the browser from refreshing the page when the form submits.
- onAddTask(text.trim()) passes the cleaned-up input text to the parent component. The parent decides what to do with it.
What is a controlled input?
When you set value={text} on the input, React takes control of what the input displays. The onChange handler updates state on every keystroke, and the input re-renders with the new value.
This pattern means your component state is always in sync with what the user sees. You never have to reach into the DOM to read the input's value.
- Save src/components/TaskForm.jsx.
You won't see a change in the browser yet. The next substep imports this component into App.jsx.
File not saving?
- Make sure the file is inside src/components/, not directly in src/. The import path in the next step expects this location.
- Confirm the filename is exactly TaskForm.jsx with a capital T and capital F.
Help me check my file structure is correct.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react'
function TaskForm({ onAddTask }) {
const [text, setText] = useState('')
function handleSubmit(e) {
e.preventDefault()
if (text.trim() === '') return
onAddTask(text.trim())
setText('')
}
return (
<form className="task-form" onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add Task</button>
</form>
)
}
export default TaskForm
Wire TaskForm into App.jsx
Your TaskForm component is ready, but it needs a parent to tell it what to do when a task is submitted. App.jsx will own the tasks state and pass a handler function down as a prop.
- Open src/App.jsx and replace all of its content with this code:
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
function App() {
// Store all tasks in an array
const [tasks, setTasks] = useState([])
function addTask(text) {
// Create a new task object with a unique id
const newTask = {
id: Date.now(),
text,
completed: false,
}
// Add the new task to the existing array without mutating it
setTasks([...tasks, newTask])
}
return (
<div className="app">
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
</div>
)
}
export default App
What does this code do?
- useState([]) initializes tasks as an empty array. This is where all your task objects will live.
- addTask builds a task object with three properties: a unique id from Date.now(), the text the user typed, and a completed flag set to false.
- [...tasks, newTask] creates a brand new array containing all existing tasks plus the new one. React requires this immutable pattern to detect changes and re-render.
- <TaskForm onAddTask={addTask} /> renders the TaskForm and passes addTask as a prop. When the form submits, TaskForm calls this function.
Why does App own the state?
In React, data flows one direction: from parent to child through props. Since multiple components will eventually need access to the tasks array (the form adds tasks, the list displays them), the state lives in their common parent.
This pattern is called "lifting state up." The parent owns the data and passes it down to whichever children need it.
- Save src/App.jsx.
- Check your browser. You should see a text input field and an **Add Task** button below the Task Manager heading.
Don't see the form?
- Check that the import path is exactly ./components/TaskForm.jsx. A wrong path causes a blank page or module-not-found error in the terminal.
- Make sure you saved both files: TaskForm.jsx and App.jsx. Unsaved changes won't trigger a hot reload.
- Check your terminal for red error messages. The error text usually points to the exact file and line number causing the issue.
Help me debug why my TaskForm isn't showing up.
Add a task counter to verify state
The form looks right, but how do you know tasks are actually being stored in state? You'll add a temporary counter that displays how many tasks exist.
- In src/App.jsx, find this line inside the return statement:
<TaskForm onAddTask={addTask} />
</div>
- Add a new line between <TaskForm onAddTask={addTask} /> and </div> so it looks like this:
<TaskForm onAddTask={addTask} />
<p>{tasks.length}</p>
</div>
What does this line do?
{tasks.length} renders the current number of items in the tasks array directly into the page. Every time state updates, React re-renders this number automatically.
- Save src/App.jsx.
- In your browser, type a task name into the input and click Add Task. The number below the form should change from 0 to 1.
- Add a second task. The number should increase to 2.
Counter stuck at 0?
- Make sure the input is not empty when you click Add Task. The if (text.trim() === '') return guard in TaskForm blocks empty submissions.
- Confirm that onAddTask in TaskForm.jsx matches onAddTask={addTask} in App.jsx. The prop name must be identical in both files.
- Check that you're using setTasks([...tasks, newTask]) and not tasks.push(newTask). Pushing mutates the array directly, which React cannot detect.
Help me figure out why my task counter isn't updating.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
function App() {
const [tasks, setTasks] = useState([])
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false,
}
setTasks([...tasks, newTask])
}
return (
<div className="app">
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
<p>{tasks.length}</p>
</div>
)
}
export default App
import { useState } from 'react'
function TaskForm({ onAddTask }) {
const [text, setText] = useState('')
function handleSubmit(e) {
e.preventDefault()
if (text.trim() === '') return
onAddTask(text.trim())
setText('')
}
return (
<form className="task-form" onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a new task..."
/>
<button type="submit">Add Task</button>
</form>
)
}
export default TaskForm
Your TaskForm is working and tasks are being stored in state. Next up, you'll build the components that display and interact with those tasks.
Render the Task List with TaskItem Components
Right now your app can store tasks in state, but you can only see a number counting up. The tasks themselves are invisible.
In this step, you'll build two new components that make tasks visible and interactive. Each task gets its own TaskItem component with a checkbox and delete button. A TaskList component wraps them all together from the tasks array.
In this step, get ready to:
- Build a TaskItem component that displays a single task with toggle and delete actions.
- Build a TaskList component that renders multiple TaskItems from an array.
- Wire up state update functions so tasks can be toggled and deleted.
Create the TaskItem component
Each task in your list needs its own visual representation. A dedicated component handles displaying the task text, a checkbox for marking completion, and a delete button.
- Create a new file src/components/TaskItem.jsx inside the components folder you made in the last step.
- Add the following code to define the TaskItem component:
function TaskItem({ task, onToggle, onDelete }) {
return (
<li className={`task-item ${task.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span className="task-text">{task.text}</span>
<button className="delete-btn" onClick={() => onDelete(task.id)}>
Delete
</button>
</li>
)
}
export default TaskItem
What does this code do?
TaskItem receives three props: the task object itself, an onToggle callback, and an onDelete callback. The component doesn't own or change data. It just displays it and calls back up when something happens.
The className uses a template literal to conditionally add the completed CSS class. When the task is marked done, this class applies a strikethrough style (once we add CSS later).
What is conditional rendering?
The ternary expression task.completed ? 'completed' : '' is conditional rendering in action. React lets you use JavaScript expressions directly in your JSX to change what gets rendered based on state.
- Save src/components/TaskItem.jsx.
- You won't see a change in the browser yet. The next component, TaskList, uses TaskItem to render the full list.
Seeing a red error in the browser?
If your browser shows an error, check that you saved the file with the .jsx extension, not .js. Vite needs the .jsx extension to process React's JSX syntax.
Also confirm the file is inside src/components/ and not directly in src/.
Why isn't my TaskItem component working?
✔️ 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
function TaskItem({ task, onToggle, onDelete }) {
return (
<li className={`task-item ${task.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span className="task-text">{task.text}</span>
<button className="delete-btn" onClick={() => onDelete(task.id)}>
Delete
</button>
</li>
)
}
export default TaskItem
Create the TaskList component
A single TaskItem handles one task. You need a parent component that takes the full array of tasks and renders a TaskItem for each one.
- Create a new file src/components/TaskList.jsx.
- Add the following code to define the TaskList component:
import TaskItem from './TaskItem.jsx'
function TaskList({ tasks, onToggle, onDelete }) {
// Show a message when no tasks match the current view
if (tasks.length === 0) {
return <p className="empty-state">No tasks to show.</p>
}
return (
<ul className="task-list">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
)
}
export default TaskList
What does this code do?
TaskList first checks if the tasks array is empty. If it is, it returns an empty-state message instead of rendering a list. This is another form of conditional rendering.
The tasks.map() call loops over every task and returns a TaskItem component for each one. The key={task.id} prop tells React which item is which so it can efficiently update only the items that changed.
Why does React need keys?
When you render a list, React needs a stable unique identifier on each item. Without keys, React has to guess which items moved, changed, or were removed. With keys, it knows exactly which DOM elements to update.
Using task.id (a timestamp from Date.now()) works well because each task gets a unique number when created.
- Save src/components/TaskList.jsx.
- You won't see a change in the browser yet. The next substep wires TaskList into App so everything connects.
Seeing an import error?
Make sure the import path at the top of TaskList.jsx is './TaskItem.jsx' with a leading ./. Both files live in the same folder, so you need the relative path prefix.
Why is my TaskList import failing?
✔️ 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 TaskItem from './TaskItem.jsx'
function TaskList({ tasks, onToggle, onDelete }) {
if (tasks.length === 0) {
return <p className="empty-state">No tasks to show.</p>
}
return (
<ul className="task-list">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
)
}
export default TaskList
Wire everything together in App.jsx
Your two new components are ready but disconnected. App.jsx needs functions that toggle and delete tasks, then it passes those functions down to TaskList as props. This pattern is called lifting state up. The parent owns the data and the children request changes through callbacks.
- Switch back to src/App.jsx in Cursor.
- Replace the entire contents of the file with this updated code:
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/TaskList.jsx'
function App() {
const [tasks, setTasks] = useState([])
// Create a new task object and add it to the array
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false,
}
setTasks([...tasks, newTask])
}
// Flip the completed status of one task (immutable update with map)
function toggleTask(id) {
const updated = tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
setTasks(updated)
}
// Remove one task from the array (immutable update with filter)
function deleteTask(id) {
const updated = tasks.filter((task) => task.id !== id)
setTasks(updated)
}
return (
<div className="app">
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
<TaskList
tasks={tasks}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</div>
)
}
export default App
What's new in this code?
- toggleTask uses .map() to create a new array. For the matching task it flips completed. For every other task it returns the original unchanged.
- deleteTask uses .filter() to return a new array containing every task except the one whose id matches.
- Both functions create new arrays instead of modifying the existing one. This is called an immutable state update. React detects the new array reference and re-renders the component.
- The temporary <p>{tasks.length}</p> line is gone. TaskList now handles displaying the tasks visually.
Why immutable updates?
React only re-renders when it detects that state has changed. If you mutate the existing array (e.g. tasks[0].completed = true), the array reference stays the same and React never notices the update. Creating a new array with map or filter gives React a fresh reference so it knows to re-render.
- Save src/App.jsx.
- Return to your browser. You should see the text "No tasks to show." where the number used to be.
- Type a task in the input and click Add Task. You should see the task appear as a list item with a checkbox and a Delete button.
- Click the checkbox. The task text should not visually change yet (we haven't added CSS), but the checkbox toggles on and off.
- Click Delete. The task should disappear from the list.
Tasks not appearing when you submit?
- Check that you imported TaskList at the top of App.jsx: import TaskList from './components/TaskList.jsx'.
- Confirm the tasks prop on TaskList points to your tasks state variable, not an empty array literal.
- Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for error messages. A typo in a prop name often shows as a React warning there.
Help me debug why tasks aren't showing up in my TaskList.
✔️ Awesome, I've got everything!
Great. Make sure you've saved all three files before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/TaskList.jsx'
function App() {
const [tasks, setTasks] = useState([])
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false,
}
setTasks([...tasks, newTask])
}
function toggleTask(id) {
const updated = tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
setTasks(updated)
}
function deleteTask(id) {
const updated = tasks.filter((task) => task.id !== id)
setTasks(updated)
}
return (
<div className="app">
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
<TaskList
tasks={tasks}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</div>
)
}
export default App
Your task list is fully functional. You can add, toggle, and delete tasks. Next up, you'll add a filter bar so users can switch between viewing all tasks, only active ones, or only completed ones. You'll also add proper styling to make the app look polished.
Add Filtering and Styling
Your Task Manager can add, complete, and delete tasks. But as your list grows, you need a way to focus on what matters right now.
In this step, you will build a FilterBar component that lets users switch between All, Active, and Completed views. You will also add persistence so tasks survive a page refresh, and apply CSS styling to make the app look polished.
In this step, get ready to:
- Create a FilterBar component with All, Active, and Completed buttons.
- Wire up filter logic and localStorage persistence in App.jsx.
- Style the entire app with a custom CSS file.
Create the FilterBar component
The FilterBar component gives users three buttons to control which tasks are visible. Each button tells the parent component (App) which filter is active by calling a prop function.
- Create a new file src/components/FilterBar.jsx and add the following code:
function FilterBar({ filter, onFilterChange }) {
return (
<div className="filter-bar">
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => onFilterChange('all')}
>
All
</button>
<button
className={filter === 'active' ? 'active' : ''}
onClick={() => onFilterChange('active')}
>
Active
</button>
<button
className={filter === 'completed' ? 'active' : ''}
onClick={() => onFilterChange('completed')}
>
Completed
</button>
</div>
)
}
export default FilterBar
What does this code do?
FilterBar receives two props from its parent: filter (the currently active filter string) and onFilterChange (a function to call when the user picks a different filter).
Each button uses a ternary expression to conditionally apply the active CSS class. This is conditional rendering applied to a class name instead of an entire element.
- Save FilterBar.jsx.
Seeing a syntax error?
Make sure each button's className uses curly braces around the ternary, not quotes. The pattern is className={expression} not className="expression".
Help me fix a className syntax error in my FilterBar component.
Wire up filtering and persistence in App.jsx
Now you need App to track which filter is active, compute a filtered list, and render the FilterBar. While refactoring the state logic, you will also add localStorage persistence so tasks survive a page refresh.
- Replace the entire contents of src/App.jsx with the following code:
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/TaskList.jsx'
import FilterBar from './components/FilterBar.jsx'
function App() {
// Initialize tasks from localStorage, or start with an empty array
const [tasks, setTasks] = useState(() => {
const saved = localStorage.getItem('tasks')
return saved ? JSON.parse(saved) : []
})
const [filter, setFilter] = useState('all')
// Helper that updates state AND saves to localStorage
function saveTasks(newTasks) {
setTasks(newTasks)
localStorage.setItem('tasks', JSON.stringify(newTasks))
}
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false,
}
saveTasks([...tasks, newTask])
}
function toggleTask(id) {
const updated = tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
saveTasks(updated)
}
function deleteTask(id) {
const updated = tasks.filter((task) => task.id !== id)
saveTasks(updated)
}
// Compute filtered list based on current filter value
const filteredTasks = tasks.filter((task) => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})
return (
<div className="app">
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
<FilterBar filter={filter} onFilterChange={setFilter} />
<TaskList
tasks={filteredTasks}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</div>
)
}
export default App
What changed in App.jsx?
- useState now takes a function initializer that reads from localStorage. This runs once on first render and loads any previously saved tasks.
- saveTasks is a helper that updates React state AND writes to localStorage in one step. All three handler functions now call saveTasks instead of setTasks directly.
- filteredTasks derives a new array from tasks based on the current filter. TaskList now receives this filtered array instead of the full list.
- FilterBar gets the current filter value and setFilter as its onFilterChange prop. Clicking a filter button updates state, which triggers a re-render with the new filtered list.
Why not store the filtered list as state?
The filteredTasks variable is computed from tasks and filter on every render. In React, if a value can be derived from existing state, you compute it instead of storing it separately. This avoids bugs where two pieces of state get out of sync.
- Save App.jsx.
- Switch to your browser. You should see the three filter buttons (All, Active, Completed) appear between the form and the task list.
- Add a task, check it off, then click **Active** and **Completed** to confirm filtering works.
Filter buttons not showing up?
Check that the import path at the top of App.jsx says ./components/FilterBar.jsx and that your file is saved as FilterBar.jsx (capital F, capital B) inside the components folder.
Help me debug why my FilterBar component is not rendering.
Add CSS styling
The app works but looks unstyled. Your main.jsx already imports index.css, so any styles you add there will apply immediately.
- Open src/index.css and replace all its contents with the following base layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
min-height: 100vh;
display: flex;
justify-content: center;
padding-top: 60px;
}
.app {
width: 100%;
max-width: 500px;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 24px;
font-size: 2rem;
color: #1a1a1a;
}
.task-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.task-form input {
flex: 1;
padding: 10px 14px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.task-form input:focus {
border-color: #646cff;
}
.task-form button {
padding: 10px 18px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.task-form button:hover {
background-color: #535bf2;
}
What do these styles do?
- The universal reset (*) removes default browser margins and padding so spacing is consistent.
- The body uses flexbox to center the app horizontally with padding at the top.
- The .task-form styles use flexbox to put the input and button side by side. The input grows to fill available space with flex: 1.
- Save index.css and check your browser.
- You should see the app centered on a light gray background with the form input and button styled side by side.
- Now add the component styles below the existing code in src/index.css:
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
justify-content: center;
}
.filter-bar button {
padding: 6px 14px;
border: 2px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.filter-bar button.active {
border-color: #646cff;
color: #646cff;
font-weight: 600;
}
.filter-bar button:hover {
border-color: #646cff;
}
.task-list {
list-style: none;
}
.task-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: white;
border-radius: 6px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: opacity 0.2s;
}
.task-item.completed {
opacity: 0.6;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: #999;
}
.task-text {
flex: 1;
font-size: 1rem;
}
.delete-btn {
padding: 4px 10px;
background: none;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #999;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.delete-btn:hover {
border-color: #ff4444;
color: #ff4444;
}
.empty-state {
text-align: center;
color: #999;
padding: 32px 0;
font-size: 1rem;
}
What do these component styles do?
- .filter-bar button.active highlights whichever filter is currently selected with a colored border and text.
- .task-item.completed lowers opacity and adds a strikethrough to completed tasks, making them visually distinct.
- .delete-btn:hover turns the delete button red on hover, signaling a destructive action.
- Save index.css and refresh your browser.
- Your tasks should appear as white cards with rounded corners. Completed tasks show strikethrough text. The filter buttons are centered with the active one highlighted in blue.
- Hover over a delete button to confirm it turns red.
Styles not applying?
Confirm that src/main.jsx has the line import './index.css' near the top. Without this import, Vite will not bundle your CSS.
Also check that your class names in the CSS match the ones in your JSX exactly. For example, className="task-form" in JSX must match .task-form in CSS.
Help me debug why my CSS styles are not showing in my React app.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/App.jsx:
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/TaskList.jsx'
import FilterBar from './components/FilterBar.jsx'
function App() {
const [tasks, setTasks] = useState(() => {
const saved = localStorage.getItem('tasks')
return saved ? JSON.parse(saved) : []
})
const [filter, setFilter] = useState('all')
function saveTasks(newTasks) {
setTasks(newTasks)
localStorage.setItem('tasks', JSON.stringify(newTasks))
}
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false,
}
saveTasks([...tasks, newTask])
}
function toggleTask(id) {
const updated = tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
saveTasks(updated)
}
function deleteTask(id) {
const updated = tasks.filter((task) => task.id !== id)
saveTasks(updated)
}
const filteredTasks = tasks.filter((task) => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})
return (
<div className="app">
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
<FilterBar filter={filter} onFilterChange={setFilter} />
<TaskList
tasks={filteredTasks}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</div>
)
}
export default App
Here is the complete src/components/FilterBar.jsx:
function FilterBar({ filter, onFilterChange }) {
return (
<div className="filter-bar">
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => onFilterChange('all')}
>
All
</button>
<button
className={filter === 'active' ? 'active' : ''}
onClick={() => onFilterChange('active')}
>
Active
</button>
<button
className={filter === 'completed' ? 'active' : ''}
onClick={() => onFilterChange('completed')}
>
Completed
</button>
</div>
)
}
export default FilterBar
Here is the complete src/index.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #333;
min-height: 100vh;
display: flex;
justify-content: center;
padding-top: 60px;
}
.app {
width: 100%;
max-width: 500px;
padding: 0 20px;
}
h1 {
text-align: center;
margin-bottom: 24px;
font-size: 2rem;
color: #1a1a1a;
}
.task-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.task-form input {
flex: 1;
padding: 10px 14px;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.task-form input:focus {
border-color: #646cff;
}
.task-form button {
padding: 10px 18px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.task-form button:hover {
background-color: #535bf2;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
justify-content: center;
}
.filter-bar button {
padding: 6px 14px;
border: 2px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.filter-bar button.active {
border-color: #646cff;
color: #646cff;
font-weight: 600;
}
.filter-bar button:hover {
border-color: #646cff;
}
.task-list {
list-style: none;
}
.task-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: white;
border-radius: 6px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: opacity 0.2s;
}
.task-item.completed {
opacity: 0.6;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: #999;
}
.task-text {
flex: 1;
font-size: 1rem;
}
.delete-btn {
padding: 4px 10px;
background: none;
border: 1px solid #e0e0e0;
border-radius: 4px;
color: #999;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.delete-btn:hover {
border-color: #ff4444;
color: #ff4444;
}
.empty-state {
text-align: center;
color: #999;
padding: 32px 0;
font-size: 1rem;
}
Your Task Manager now filters tasks, persists them across refreshes, and looks polished. Next up, you will test that persistence end-to-end and confirm your app handles all the edge cases.
Persist Tasks with localStorage
Your Task Manager works great right now, but there's a problem. If you refresh the browser, every task you added vanishes.
React state lives in memory only. In this step, you'll connect your state to the browser's localStorage API so tasks survive page refreshes.
In this step, get ready to:
- Create a helper function that saves tasks to both state and localStorage.
- Initialize state from localStorage so tasks load on refresh.
- Verify persistence works across page refreshes.
Create a saveTasks helper and update your handler functions
Right now, addTask, toggleTask, and deleteTask each call setTasks directly. To persist data, each one also needs to write to localStorage. Instead of duplicating that logic in three places, you'll create one helper that handles both.
- In src/App.jsx, find the line that says const [filter, setFilter] = useState('all').
- Add the following saveTasks function directly below it:
function saveTasks(newTasks) {
setTasks(newTasks)
localStorage.setItem('tasks', JSON.stringify(newTasks))
}
What does this code do?
saveTasks does two things every time the task list changes. It updates React state with setTasks so the UI re-renders, and it writes the new array to localStorage as a JSON string so the browser remembers it.
Now you need to update your three handler functions to call saveTasks instead of setTasks.
- In the addTask function, find this line:
setTasks([...tasks, newTask])
- Replace setTasks with saveTasks, so the line now looks like this:
saveTasks([...tasks, newTask])
- In the toggleTask function, find this line:
setTasks(updated)
- Replace setTasks with saveTasks:
saveTasks(updated)
- In the deleteTask function, find the same setTasks(updated) line and replace it with saveTasks(updated).
You won't see a change yet. The next substep connects the read side so localStorage actually loads on refresh.
Initialize state from localStorage
You're now writing tasks to localStorage, but your app still starts with an empty array every time it loads. You need to read from localStorage when the component first renders.
- In src/App.jsx, find this line:
const [tasks, setTasks] = useState([])
- Replace useState([]) with a function initializer that reads from localStorage:
const [tasks, setTasks] = useState(() => {
const saved = localStorage.getItem('tasks')
return saved ? JSON.parse(saved) : []
})
What does this code do?
Passing a function to useState makes it a "lazy initializer." React calls this function only once on the very first render.
Inside, localStorage.getItem('tasks') retrieves the saved JSON string. If it exists, JSON.parse converts it back to an array. If nothing is saved yet, it falls back to an empty array.
- Save src/App.jsx.
Seeing a blank page or console error?
Check that your JSON.parse call is wrapped correctly. A missing closing parenthesis or bracket will cause a syntax error.
Make sure you kept the arrow function () => at the start. Without it, localStorage.getItem runs on every render instead of just once.
Help me debug my localStorage initializer in App.jsx.
Test the persistence
Both the read and write sides are connected. Time to prove it works.
- Switch to your browser with the Task Manager open.
- Add two or three tasks using the form.
- Refresh the page with Cmd+R (macOS) or Ctrl+R (Windows).
- Your tasks should still be there.
- Toggle one task as completed and delete another task.
- Refresh the page again.
- Confirm the toggled task is still checked and the deleted task is gone.
Why not use useEffect for persistence?
Many tutorials use a useEffect hook to sync state to localStorage. That approach works, but it introduces a more complex hook that fires after renders.
Calling localStorage inside your handler functions is simpler. You save at the exact moment data changes, with no extra re-render cycle to reason about.
Tasks disappearing on refresh?
Make sure all three handler functions call saveTasks instead of setTasks. If even one still calls setTasks directly, that change won't reach localStorage.
Check that the key string 'tasks' is spelled the same in both localStorage.setItem and localStorage.getItem. A mismatch means the read can't find what the write saved.
Help me debug why my tasks aren't persisting after refresh.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.jsx and confirmed tasks persist across refreshes.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/TaskList.jsx'
import FilterBar from './components/FilterBar.jsx'
function App() {
const [tasks, setTasks] = useState(() => {
const saved = localStorage.getItem('tasks')
return saved ? JSON.parse(saved) : []
})
const [filter, setFilter] = useState('all')
function saveTasks(newTasks) {
setTasks(newTasks)
localStorage.setItem('tasks', JSON.stringify(newTasks))
}
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false,
}
saveTasks([...tasks, newTask])
}
function toggleTask(id) {
const updated = tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
saveTasks(updated)
}
function deleteTask(id) {
const updated = tasks.filter((task) => task.id !== id)
saveTasks(updated)
}
const filteredTasks = tasks.filter((task) => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})
return (
<div className="app">
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
<FilterBar filter={filter} onFilterChange={setFilter} />
<TaskList
tasks={filteredTasks}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</div>
)
}
export default App
Your Task Manager now remembers everything. Tasks, completions, and deletions all survive page refreshes. Next up, you'll take on the Secret Mission and add inline editing with live task stats.
Secret mission
Add Inline Editing and Task Stats
Your Task Manager works great, but what happens when you make a typo? Right now you'd have to delete the task and re-type it from scratch. In this secret mission, you'll add double-click-to-edit functionality and a live task counter that shows how many tasks are active and completed.
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.
- The Vite development server (running process in your terminal).
- Task data stored in your browser's localStorage.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your Task Manager app.
- Your Vite dev server stays running in the terminal.
- Your project files remain on your Desktop.
- Your task data stays in localStorage and loads automatically next time you open the app.
Pause - I'll come back to this later
Shut down the running process to free up your terminal. Your project files and task data will still be there when you come back.
- Switch to the terminal where your Vite dev server is running.
- Press Ctrl+C to stop the server.
- When you want to resume, open the task-manager folder in Cursor and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and stored data to start fresh.
- Switch to the terminal where your Vite dev server is running and press Ctrl+C to stop it.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/task-manager
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\task-manager
- Clear your task data from localStorage. Open your browser, press F12 to open DevTools, go to the Console tab, and run:
localStorage.removeItem('tasks')
Nice Work!
Nice Work!
Nice work! You just built a fully interactive Task Manager application from scratch using React and Vite.
You've learned how to:
- Created reusable components (TaskForm, TaskItem, TaskList, FilterBar) and composed them together with props and one-way data flow.
- Managed application state with useState, handled user events, and updated state immutably using map, filter, and the spread operator.
- Rendered lists with unique keys, applied conditional rendering for filters and empty states, and persisted data to localStorage for cross-session storage.
- Secret Mission: Implemented inline editing with per-component state and derived computed values (active/completed counts) from the tasks array.
Ready to quiz yourself?