Build a Task Tracker with React
Build a React task tracker app with components, state, and event handling.
Introduction
30 Second Summary
Everyone has a to-do list. Whether it's sticky notes on your desk or a mental list of things to finish before the day ends, tracking tasks is something you do every single day.
In this project, you will build a task tracker app with React that lets you add tasks, mark them as complete, and delete them.
What You'll Build
A React application with multiple components that manage state and handle user interactions in real time.
By the end of this project, you'll have:
- A fully functional React app scaffolded with Vite and running locally with hot-reload.
- Multiple custom components that pass data using props and manage state with the useState hook.
- Event handling for adding, completing, and deleting tasks in real time.
- Secret Mission: Add filtering so users can toggle between viewing all tasks, only active tasks, or only completed tasks.
Are there any prerequisites?
This project assumes a basic understanding of HTML, CSS, and JavaScript fundamentals. You do not need any prior React experience.
Before We Start
Before We Start...
Set Up Your Development Environment
To build the task tracker, you need two things on your computer: a JavaScript runtime to execute code, and a build tool to scaffold and serve your React project.
In this step, you will install Node.js (the runtime), then use Vite (the build tool) to create a ready-to-go React project with hot-reload.
In this step, get ready to:
- Install and verify Node.js.
- Scaffold a React project with Vite.
- Start the development server and see the app in your browser.
Install and verify Node.js
Node.js is a JavaScript runtime that lets you run JavaScript outside the browser. Vite and React both depend on it.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check whether Node.js is already installed by running this command:
node --version
✔️ I see version 20.19 or higher
Node.js is ready. Move on to the next substep.
ⓧ I see an older version
You have Node.js installed, but it is below the required 20.19 minimum that Vite needs.
- Visit https://nodejs.org and download the LTS installer for your operating system.
- Run the installer, accepting the defaults.
- Close and reopen your terminal so the new version takes effect.
- Run node --version again to confirm you see 20.19 or higher.
Why does the version matter?
Vite requires Node.js version 20.19+ or 22.12+ to run. Older versions are missing features Vite depends on.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit https://nodejs.org and download the LTS installer for your operating system.
- Run the installer, accepting the defaults.
- Close and reopen your terminal so Node.js is available on your PATH.
- Run node --version again to confirm you see 20.19 or higher.
- Verify that npm (the package manager bundled with Node.js) is available by running:
npm --version
You should see a version number printed (any version is fine here).
Still seeing command not found?
Make sure you fully closed and reopened your terminal after installing Node.js. The PATH update only takes effect in new terminal sessions.
help me troubleshoot Node.js installation on my machine
Scaffold a new React project with Vite
Vite is a modern build tool that creates a ready-to-run project structure and provides instant hot-reload during development.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your React project by running this command:
npm create vite@latest task-tracker -- --template react
What does this command do?
npm create vite@latest runs the latest version of Vite's project scaffolder.
task-tracker is the folder name for your new project. The --template react flag tells Vite to use the React starter template with JavaScript.
- Confirm the folder was created by running:
ls
You should see task-tracker listed in the output.
- Move into the project folder and install its dependencies by running these commands:
cd task-tracker
npm install
You should see output listing installed packages. This may take 30 seconds or so.
Seeing permission errors or network failures?
Permission errors on macOS can often be fixed by prefixing with sudo. Network errors usually mean a firewall or proxy is blocking npm. Check your internet connection and try again.
help me fix npm install errors
- Replace the default scaffold styles with a clean starting point. Open src/index.css in any text editor and replace its entire contents with the following:
:root {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #213547;
background-color: #ffffff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
Why replace the default styles?
The Vite scaffold includes demo-specific CSS (dark mode, custom properties for the landing page). Replacing it with these minimal global resets gives you a clean canvas for your own styles in the next step.
- Save src/index.css.
Start the development server
Vite includes a dev server that rebuilds your app instantly whenever you save a file. This is called hot module replacement (HMR).
- Start the development server by running this command:
npm run dev
You should see output that includes a local URL, typically http://localhost:5173.
- Open that URL in your browser.
- You should see the default Vite + React landing page with a counter button and logos.
Keep this server running
Leave this terminal window open with the dev server running for the rest of the project. Every time you save a file, your browser will update automatically.
Don't see the page in your browser?
Make sure the URL matches what your terminal printed. If port 5173 was busy, Vite may have chosen a different port like 5174.
Check that npm run dev is still running in your terminal. If you see an error instead of a URL, read the error message for clues.
help me get the Vite dev server running
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/index.css with the simplified styles, and keep the dev server running in your terminal.
ⓧ I'd like to double check the full code
Here is what your key files should look like at this point.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
:root {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #213547;
background-color: #ffffff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
The src/main.jsx file is generated by Vite and should not be modified. The src/index.css file should contain only the minimal global styles shown above.
Your development environment is ready and the React app is live in your browser. Next up, you will clear out the default demo content and build your first custom components.
Build the App Shell and Header Component
Your development environment is ready and the default Vite page is running in your browser. Now it's time to start building your own UI.
Every React app is made of components. Components are reusable building blocks that each handle one piece of your interface. In this step, you'll create the shell of your app and your first custom component.
In this step, get ready to:
- Clear out the default Vite boilerplate and create a clean App shell.
- Build a Header component that accepts data through props.
- Style the app layout with CSS.
Clear out the boilerplate and create the App shell
The Vite scaffold comes with demo content (a spinning logo and a counter). You need a blank canvas to build your task tracker on.
- In VS Code, open src/App.css from the Explorer sidebar.
- Select all the content (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
- Save the now-empty file.
- Open src/App.jsx and select all the content.
- Replace everything with the following code:
import './App.css'
function App() {
return (
<div className="app">
</div>
)
}
export default App
What does this code do?
- import './App.css' connects your stylesheet to this component.
- function App() defines your root component as a JavaScript function that returns JSX (HTML-like syntax).
- className="app" is how you assign CSS classes in JSX (instead of the HTML class attribute).
- export default App makes this component available to other files (like main.jsx which renders it).
- Save src/App.jsx.
- Check your browser. The spinning logo and counter are gone. You should see a blank white page.
Still seeing the old Vite page?
- Make sure you saved both src/App.css and src/App.jsx. Unsaved changes won't trigger a hot-reload.
- Confirm your dev server is still running in the terminal. If it stopped, run npm run dev again.
Help me figure out why my browser still shows the default Vite page after I cleared App.jsx.
Create the Header component
Components become powerful when they accept data from the outside. In React, you pass data into a component using props (short for properties).
- Create a new file called src/Header.jsx in the Explorer sidebar.
- Add the following code to Header.jsx:
function Header({ title, count }) {
return (
<header className="header">
<h1>{title}</h1>
<p>{count} {count === 1 ? 'task' : 'tasks'} remaining</p>
</header>
)
}
export default Header
What does this code do?
- { title, count } destructures the props object so you can use title and count directly.
- {title} inside JSX outputs the value of the title prop as text. Curly braces let you embed JavaScript expressions in JSX.
- count === 1 ? 'task' : 'tasks' is a ternary that picks the correct singular or plural label.
- Save src/Header.jsx.
- You won't see a change in the browser yet because App doesn't use this component. The next chunk wires it in.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/Header.jsx before moving on.
ⓧ I'd like to double check the full code
function Header({ title, count }) {
return (
<header className="header">
<h1>{title}</h1>
<p>{count} {count === 1 ? 'task' : 'tasks'} remaining</p>
</header>
)
}
export default Header
- Now update src/App.jsx to import and use the Header component. Replace the entire file with this code:
import Header from './Header.jsx'
import './App.css'
function App() {
return (
<div className="app">
<Header title="Task Tracker" count={0} />
</div>
)
}
export default App
What does this code do?
- import Header from './Header.jsx' brings your new component into this file so you can use it like an HTML tag.
- <Header title="Task Tracker" count={0} /> renders the Header and passes two props: a string title and a number count.
- count={0} is a placeholder. You'll replace it with a real task count in a later step.
Why props instead of hardcoding?
Props let one component work in many situations. Your Header doesn't know or care where title or count come from. It just displays whatever it receives.
This is React's one-way data flow: parent components pass data down to children, never the other direction.
- Save src/App.jsx.
- Check your browser. You should see the heading "Task Tracker" and "0 tasks remaining" centered on the page.
Don't see Task Tracker in the browser?
- Check that src/Header.jsx exists in the same folder as App.jsx. The import path './Header.jsx' means "same directory."
- Confirm the filename is exactly Header.jsx with a capital H. File names are case-sensitive.
- Look in your terminal for a red error message. It often names the exact file or line that has the problem.
Help me debug why my Header component isn't showing up in the browser.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both src/App.jsx and src/Header.jsx.
ⓧ I'd like to double check the full code
import Header from './Header.jsx'
import './App.css'
function App() {
return (
<div className="app">
<Header title="Task Tracker" count={0} />
</div>
)
}
export default App
Style the app layout
The text is showing, but it's unstyled and stuck to the left edge. A few CSS rules will center the app and give the header a polished look.
- Open src/App.css (it should still be empty from earlier).
- Add the following styles:
.app {
max-width: 500px;
margin: 40px auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
margin: 0;
color: #1a1a2e;
}
.header p {
color: #666;
margin: 8px 0 0;
}
What do these styles do?
- .app constrains the layout to 500px and centers it horizontally with margin: 40px auto.
- .header centers the text and adds spacing below it so future content doesn't crowd the heading.
- .header h1 removes the default margin browsers add and sets a dark color.
- .header p styles the task counter in a muted gray.
- Save src/App.css.
- Check your browser. "Task Tracker" should now be centered on the page with "0 tasks remaining" in gray below it.
Styles not applying?
- Make sure import './App.css' is present in your App.jsx. Without this import, the CSS file is never loaded.
- Verify that the class name in your JSX (className="app") matches the selector in your CSS (.app). A typo in either breaks the connection.
Help me figure out why my CSS styles aren't showing up in my React app.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.css and the heading looks centered.
ⓧ I'd like to double check the full code
.app {
max-width: 500px;
margin: 40px auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
margin: 0;
color: #1a1a2e;
}
.header p {
color: #666;
margin: 8px 0 0;
}
Your app shell is in place and your first component is rendering real data through props. Next up, you'll build the form that lets users type in new tasks.
Create the Task Form Component
Your app shell is in place with a header displaying the title and task count.
Now you need a way for users to actually add tasks. In this step, you will build a TaskForm component that captures text input and passes it up to the parent when submitted.
In this step, get ready to:
- Build a TaskForm component with a text input and submit button.
- Track the input value using React's useState hook.
- Handle form submission to pass the task text up to the parent component.
Create the TaskForm component file
Every React component that handles user input needs two things: a piece of state to track what the user is typing, and a function to handle what happens when they submit.
- Create a new file called src/TaskForm.jsx in your project and add the following code:
import { useState } from 'react'
function TaskForm({ onAddTask }) {
// Track what the user is typing
const [text, setText] = useState('')
// Handle form submission
function handleSubmit(e) {
e.preventDefault()
if (text.trim() === '') return
onAddTask(text.trim())
setText('')
}
return (
<form className="task-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Add a new task..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
</form>
)
}
export default TaskForm
What does this code do?
This component renders a <form> element containing a text input and an Add button.
- The { onAddTask } destructures a callback prop from the parent. This is how the form sends the new task text upward.
- The className="task-form" gives the form a CSS class you will style shortly.
- The <input> has a placeholder attribute so users know what to type.
- Save src/TaskForm.jsx.
You will not see a change in the browser yet. The next substep wires TaskForm into your app.
Use useState to track the input value
The useState hook gives your component a memory slot that persists between renders. When the user types, the onChange handler updates that slot, and React re-renders the input with the new value.
What is a controlled input?
In the code you just wrote, the input's value is always set to the text state variable. This means React controls what the input displays, not the browser. This pattern is called a controlled input.
Every keystroke fires onChange, which calls setText(e.target.value) to update state. React re-renders, and the input shows the updated text.
Now let's import the component so it appears on screen.
- Switch back to src/App.jsx and replace its contents with the following:
import Header from './Header.jsx'
import TaskForm from './TaskForm.jsx'
import './App.css'
function App() {
return (
<div className="app">
<Header title="Task Tracker" count={0} />
<TaskForm onAddTask={() => {}} />
</div>
)
}
export default App
What does this change do?
- The new import at line 2 brings in your TaskForm component.
- The onAddTask={() => {}} passes an empty placeholder function for now. You will replace this with a real function in the next step when you set up the tasks array.
- Save src/App.jsx.
- Check your browser. You should see a plain text input and a button below the header.
Don't see the input and button?
- Make sure you saved both src/TaskForm.jsx and src/App.jsx.
- Check that the import path in App.jsx says './TaskForm.jsx' with the correct capitalization.
- Look at your terminal for error messages. A typo in the file name or export will show a red error.
Help me debug why my TaskForm isn't showing up in the browser.
Style and test the task form
The form works, but it looks plain. The handleSubmit function already prevents the browser's default page-reload behaviour, trims whitespace, and clears the input after submission. Now let's add styles so the form looks polished.
How does handleSubmit work?
- e.preventDefault() stops the browser from refreshing the page when the form submits.
- if (text.trim() === '') return prevents adding empty tasks.
- onAddTask(text.trim()) sends the cleaned-up text to the parent component.
- setText('') resets the input field so the user can immediately type another task.
- Open src/App.css and add the following styles at the bottom of the file:
.task-form {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.task-form input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
}
.task-form input:focus {
outline: none;
border-color: #646cff;
}
.task-form button {
padding: 10px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.task-form button:hover {
background-color: #535bf2;
}
What do these styles do?
- display: flex places the input and button side by side.
- flex: 1 on the input makes it stretch to fill all available space.
- The :focus rule highlights the input border in purple when clicked.
- The button gets a purple background that darkens on hover.
- Save src/App.css.
- Check your browser. You should see a styled input field and a purple Add button sitting side by side below the header.
- Type some text into the input and click Add. The input field should clear itself.
Input not clearing when you click Add?
- Make sure the value={text} attribute is on your <input> in TaskForm.jsx. Without it, React cannot control the input.
- Confirm the setText('') line is inside your handleSubmit function after the onAddTask call.
- Check that the onSubmit={handleSubmit} attribute is on the <form> element, not on the button.
Help me figure out why my form input isn't clearing after submission.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files.
ⓧ I'd like to double check the full code
Here is the full code for each file you changed in this step.
import { useState } from 'react'
function TaskForm({ onAddTask }) {
// Track what the user is typing
const [text, setText] = useState('')
// Handle form submission
function handleSubmit(e) {
e.preventDefault()
if (text.trim() === '') return
onAddTask(text.trim())
setText('')
}
return (
<form className="task-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Add a new task..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
</form>
)
}
export default TaskForm
import Header from './Header.jsx'
import TaskForm from './TaskForm.jsx'
import './App.css'
function App() {
return (
<div className="app">
<Header title="Task Tracker" count={0} />
<TaskForm onAddTask={() => {}} />
</div>
)
}
export default App
.app {
max-width: 500px;
margin: 40px auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
margin: 0;
color: #1a1a2e;
}
.header p {
color: #666;
margin: 8px 0 0;
}
.task-form {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.task-form input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
}
.task-form input:focus {
outline: none;
border-color: #646cff;
}
.task-form button {
padding: 10px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.task-form button:hover {
background-color: #535bf2;
}
Your task form is ready to accept input and fire a callback. Next up, you will build the task list and connect everything with real state management so that added tasks actually appear on screen.
Create the Task List and Manage State
Your app can accept input through the form, but it has nowhere to store or display tasks.
In this step, you will add the central piece of any React app: state. State is data that changes over time and causes the UI to update automatically when it does.
In this step, get ready to:
- Add a tasks state array and an addTask function to App.
- Create a TaskList component that renders tasks with checkboxes and delete buttons.
- Wire up toggleTask and deleteTask callbacks so users can interact with their tasks.
Add state and the addTask function to App
Right now, App.jsx passes an empty arrow function to TaskForm. You need real state to hold tasks and a real function to add them.
- In src/App.jsx, replace the entire file contents with this code:
import { useState } from 'react'
import Header from './Header.jsx'
import TaskForm from './TaskForm.jsx'
import './App.css'
function App() {
// Create a state variable to hold our list of tasks
const [tasks, setTasks] = useState([])
// Add a new task object to the array
function addTask(text) {
const newTask = {
id: Date.now(),
text: text,
completed: false,
}
setTasks([...tasks, newTask])
}
return (
<div className="app">
<Header title="Task Tracker" count={0} />
<TaskForm onAddTask={addTask} />
</div>
)
}
export default App
What does this code do?
The useState([]) hook creates a tasks array and a setTasks function to update it. Whenever you call setTasks, React automatically re-renders the component with the new data.
The addTask function builds a task object with a unique id (using the current timestamp), the text the user typed, and a completed flag set to false. The spread syntax [...tasks, newTask] creates a new array with all existing tasks plus the new one.
- Save src/App.jsx.
You won't see a change in the browser yet. The next chunk creates the TaskList component that displays these tasks.
Create the TaskList component
The TaskList is responsible for rendering each task as a list item with a checkbox and a delete button. It also handles the empty state when no tasks exist.
- Create a new file called src/TaskList.jsx and add this code:
function TaskList({ tasks, onToggle, onDelete }) {
// Show a friendly message when there are no tasks
if (tasks.length === 0) {
return <p className="empty-message">No tasks yet!</p>
}
return (
<ul className="task-list">
{tasks.map((task) => (
<li key={task.id} className={task.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span>{task.text}</span>
<button onClick={() => onDelete(task.id)}>Delete</button>
</li>
))}
</ul>
)
}
export default TaskList
What does this code do?
The component receives three props: tasks (the array to display), onToggle (called when a checkbox changes), and onDelete (called when the delete button is clicked).
The .map() method loops through the tasks array and returns a <li> element for each one. The key={task.id} prop helps React track which items changed so it can update efficiently.
- Save src/TaskList.jsx.
You won't see anything new in the browser yet. The component exists but isn't imported into App yet.
Seeing a red error in the browser?
If you see an error, make sure you saved the file as TaskList.jsx (not .js) inside the src folder.
Check that the function name TaskList matches the export default TaskList at the bottom of the file.
Help me debug my TaskList component.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/TaskList.jsx.
ⓧ I'd like to double check the full code
function TaskList({ tasks, onToggle, onDelete }) {
if (tasks.length === 0) {
return <p className="empty-message">No tasks yet!</p>
}
return (
<ul className="task-list">
{tasks.map((task) => (
<li key={task.id} className={task.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span>{task.text}</span>
<button onClick={() => onDelete(task.id)}>Delete</button>
</li>
))}
</ul>
)
}
export default TaskList
Now add the styles that will make the task list look clean and readable.
- In src/App.css, scroll to the bottom of the file and add this CSS below the existing styles:
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
.task-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #eee;
}
.task-list li.completed span {
text-decoration: line-through;
color: #999;
}
.task-list li span {
flex: 1;
}
.task-list li button {
padding: 4px 12px;
background-color: #ff4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.task-list li button:hover {
background-color: #cc0000;
}
.empty-message {
text-align: center;
color: #999;
font-style: italic;
}
What do these styles do?
The .task-list styles remove default bullet points and lay each task item out in a row with a checkbox, text, and delete button.
The .completed class adds a strikethrough to finished tasks. The red delete button stands out so users can easily remove tasks.
- Save src/App.css.
You won't see these styles applied yet since TaskList isn't rendered in the app. The next substep wires everything together.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.css.
ⓧ I'd like to double check the full code
.app {
max-width: 500px;
margin: 40px auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
margin: 0;
color: #1a1a2e;
}
.header p {
color: #666;
margin: 8px 0 0;
}
.task-form {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.task-form input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
}
.task-form input:focus {
outline: none;
border-color: #646cff;
}
.task-form button {
padding: 10px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.task-form button:hover {
background-color: #535bf2;
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
.task-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #eee;
}
.task-list li.completed span {
text-decoration: line-through;
color: #999;
}
.task-list li span {
flex: 1;
}
.task-list li button {
padding: 4px 12px;
background-color: #ff4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.task-list li button:hover {
background-color: #cc0000;
}
.empty-message {
text-align: center;
color: #999;
font-style: italic;
}
Wire up toggleTask, deleteTask, and the TaskList component
The final piece is connecting everything. You need functions that toggle a task's completion status and delete tasks from the list, plus you need to render TaskList inside App.
- In src/App.jsx, replace the entire file contents with this final version:
import { useState } from 'react'
import Header from './Header.jsx'
import TaskForm from './TaskForm.jsx'
import TaskList from './TaskList.jsx'
import './App.css'
function App() {
const [tasks, setTasks] = useState([])
// Add a new task object to the array
function addTask(text) {
const newTask = {
id: Date.now(),
text: text,
completed: false,
}
setTasks([...tasks, newTask])
}
// Flip a task's completed status
function toggleTask(id) {
setTasks(
tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
)
}
// Remove a task from the array
function deleteTask(id) {
setTasks(tasks.filter((task) => task.id !== id))
}
// Count how many tasks are not yet completed
const remainingCount = tasks.filter((task) => !task.completed).length
return (
<div className="app">
<Header title="Task Tracker" count={remainingCount} />
<TaskForm onAddTask={addTask} />
<TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
</div>
)
}
export default App
What does this code do?
The toggleTask function uses .map() to create a new array where the matching task has its completed value flipped. Every other task stays the same.
The deleteTask function uses .filter() to return a new array that excludes the task with the matching id.
The remainingCount variable counts tasks where completed is false. This value gets passed to Header so the counter updates automatically.
Why create new arrays instead of modifying the existing one?
React detects state changes by comparing the old value with the new one. If you mutate the existing array (like using .push() or .splice()), React sees the same array reference and skips re-rendering.
By always returning a new array with the spread operator or .filter()/.map(), React knows something changed and updates the UI.
- Save src/App.jsx.
- Switch to your browser. You should see the text "No tasks yet!" displayed below the input form.
- Type a task name into the input field and click Add. The task appears as a list item with a checkbox and a red Delete button.
- Check the checkbox. The task text gets a strikethrough and the header counter drops by one.
- Click Delete. The task disappears from the list.
Tasks not appearing when you click Add?
Make sure the import line says import TaskList from './TaskList.jsx' with a capital T and L.
Check that you are passing onAddTask={addTask} to TaskForm (not the empty arrow function from before).
If the task appears but the checkbox or delete button does nothing, confirm you are passing onToggle={toggleTask} and onDelete={deleteTask} to TaskList.
Help me debug why my tasks are not appearing.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.jsx.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import Header from './Header.jsx'
import TaskForm from './TaskForm.jsx'
import TaskList from './TaskList.jsx'
import './App.css'
function App() {
const [tasks, setTasks] = useState([])
function addTask(text) {
const newTask = {
id: Date.now(),
text: text,
completed: false,
}
setTasks([...tasks, newTask])
}
function toggleTask(id) {
setTasks(
tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
)
}
function deleteTask(id) {
setTasks(tasks.filter((task) => task.id !== id))
}
const remainingCount = tasks.filter((task) => !task.completed).length
return (
<div className="app">
<Header title="Task Tracker" count={remainingCount} />
<TaskForm onAddTask={addTask} />
<TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
</div>
)
}
export default App
Your task tracker is fully functional. You can add, complete, and delete tasks. Next up, you will polish the app with conditional rendering and test the complete workflow.
Polish and Test the Complete App
Your components are wired together and the core logic is in place. Now it's time to confirm that the polish features you built actually work the way you expect.
In this step, you'll walk through how conditional rendering, strikethrough styling, and the dynamic task counter come together. Then you'll run a full end-to-end test of the app.
In this step, get ready to:
- Confirm the empty state message and strikethrough styling work.
- Verify the task counter updates dynamically.
- Test the complete add, toggle, and delete flow.
Confirm the empty state and strikethrough
React lets you render different content depending on a condition. Your TaskList component already uses this pattern to handle two scenarios: an empty list and a populated one.
- Switch back to your code editor and open src/TaskList.jsx.
- Find this block at the top of the function:
if (tasks.length === 0) {
return <p className="empty-message">No tasks yet!</p>
}
What does this code do?
This is conditional rendering. If the tasks array is empty, the component returns a friendly message and stops. The rest of the component (the <ul> list) never renders.
- Now find the className logic on the <li> element:
<li key={task.id} className={task.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span>{task.text}</span>
<button onClick={() => onDelete(task.id)}>Delete</button>
</li>
How does the strikethrough work?
When task.completed is true, React applies the CSS class completed to the list item. Your App.css has a rule that targets .task-list li.completed span and adds text-decoration: line-through.
When task.completed is false, the className is an empty string, so no strikethrough is applied. This toggle happens automatically every time React re-renders the list.
- Return to your browser at http://localhost:5173.
- Confirm you see the "No tasks yet!" message displayed in grey italic text.
Don't see the empty message?
Make sure your dev server is still running in the terminal. If it stopped, run npm run dev again from the task-tracker folder.
Check that src/TaskList.jsx has the if (tasks.length === 0) block before the return statement with the <ul>.
Help me debug my TaskList conditional rendering
Verify the dynamic task counter
A good task tracker tells you how much work is left. Your Header component receives a count prop and displays how many tasks are remaining.
- Open src/App.jsx and find this line:
const remainingCount = tasks.filter((task) => !task.completed).length
What does this code do?
This line filters the tasks array to only include items where completed is false, then grabs the .length of that filtered array. The result is the number of incomplete tasks.
Every time a task is added, toggled, or deleted, React re-runs the App function. This means remainingCount recalculates automatically and the Header updates in real time.
- Now open src/Header.jsx and find where the count is displayed:
<p>{count} {count === 1 ? 'task' : 'tasks'} remaining</p>
What is the ternary doing here?
The expression count === 1 ? 'task' : 'tasks' handles pluralization. If there's exactly 1 remaining task, it says "1 task remaining". For any other number, it says "tasks" (plural). Small details like this make your app feel polished.
- Return to your browser.
- The header should show "0 tasks remaining" since the list is empty.
Test the complete flow
Time to put the full app through its paces. You'll add tasks, mark some as complete, delete others, and watch everything update in real time.
- In the text input, type Buy groceries and click the Add button.
- Add two more tasks: Walk the dog and Read a book.
- Confirm the header now says "3 tasks remaining".
- Click the checkbox next to "Buy groceries".
- Confirm the task text gets a strikethrough and the counter drops to "2 tasks remaining".
- Click the Delete button next to "Walk the dog".
- Confirm the task disappears and the counter shows "1 task remaining" (singular).
- Delete all remaining tasks and confirm the "No tasks yet!" message reappears.
What should I see?
Every action updates the UI instantly without a page refresh. Adding a task makes it appear at the bottom of the list. Checking a task gives it a strikethrough. Deleting removes it from the DOM entirely. The counter in the header recalculates after each change.
This instant feedback is React's re-rendering at work. When setTasks updates the state, React re-runs the component tree and updates only the parts of the page that changed.
Tasks not updating when you click?
If checking a task doesn't apply the strikethrough, make sure your App.css has the .task-list li.completed span rule with text-decoration: line-through.
If the counter isn't changing, confirm that App.jsx passes count={remainingCount} to the Header component.
Help me debug my task tracker interactions
✔️ Awesome, I've got everything!
Great. Double check that all your files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for all your files. Compare each one to make sure nothing is off.
import { useState } from 'react'
import Header from './Header.jsx'
import TaskForm from './TaskForm.jsx'
import TaskList from './TaskList.jsx'
import './App.css'
function App() {
const [tasks, setTasks] = useState([])
function addTask(text) {
const newTask = {
id: Date.now(),
text: text,
completed: false,
}
setTasks([...tasks, newTask])
}
function toggleTask(id) {
setTasks(
tasks.map((task) =>
task.id === id ? { ...task, completed: !task.completed } : task
)
)
}
function deleteTask(id) {
setTasks(tasks.filter((task) => task.id !== id))
}
const remainingCount = tasks.filter((task) => !task.completed).length
return (
<div className="app">
<Header title="Task Tracker" count={remainingCount} />
<TaskForm onAddTask={addTask} />
<TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
</div>
)
}
export default App
function Header({ title, count }) {
return (
<header className="header">
<h1>{title}</h1>
<p>{count} {count === 1 ? 'task' : 'tasks'} remaining</p>
</header>
)
}
export default Header
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"
placeholder="Add a new task..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add</button>
</form>
)
}
export default TaskForm
function TaskList({ tasks, onToggle, onDelete }) {
if (tasks.length === 0) {
return <p className="empty-message">No tasks yet!</p>
}
return (
<ul className="task-list">
{tasks.map((task) => (
<li key={task.id} className={task.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span>{task.text}</span>
<button onClick={() => onDelete(task.id)}>Delete</button>
</li>
))}
</ul>
)
}
export default TaskList
.app {
max-width: 500px;
margin: 40px auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
margin: 0;
color: #1a1a2e;
}
.header p {
color: #666;
margin: 8px 0 0;
}
.task-form {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.task-form input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
}
.task-form input:focus {
outline: none;
border-color: #646cff;
}
.task-form button {
padding: 10px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.task-form button:hover {
background-color: #535bf2;
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
.task-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #eee;
}
.task-list li.completed span {
text-decoration: line-through;
color: #999;
}
.task-list li span {
flex: 1;
}
.task-list li button {
padding: 4px 12px;
background-color: #ff4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.task-list li button:hover {
background-color: #cc0000;
}
.empty-message {
text-align: center;
color: #999;
font-style: italic;
}
Your task tracker is fully functional. You've built a complete React app from scratch with components, props, state, and event handling. Next up is a bonus challenge to take this app even further.
Secret mission
Add Task Filtering
Your task tracker works great, but as the list grows, it gets harder to focus. What if users could toggle between viewing all tasks, only active tasks, or only completed tasks? Add filtering controls to level up your app.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The task-tracker project folder on your Desktop.
- The Vite development server running in your terminal.
- Node.js installed on your machine.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your task tracker.
- Your dev server continues running at http://localhost:5173. You can keep adding features.
- Node.js stays installed on your machine for future JavaScript projects.
- Your task-tracker folder remains on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay on disk so you can pick up where you left off.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the Vite dev server.
- When you are ready to resume, navigate back to your task-tracker folder and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh. This cannot be undone.
- Switch to the terminal where your dev server is running and press Ctrl+C to stop it.
- Delete the project folder by running:
macOS
rm -rf ~/Desktop/task-tracker
Windows
rmdir /s /q %USERPROFILE%\Desktop\task-tracker
- Confirm the folder is gone by checking your Desktop.
- To uninstall Node.js on macOS, use your package manager (e.g. brew uninstall node). On Windows, open Settings, select Apps, find Node.js in the list, and click Uninstall.
Nice Work!
Nice Work!
You built a complete task tracker app with React from scratch. From an empty scaffold to a fully interactive UI, you now have hands-on experience with the core building blocks of every React application.
You've learned how to:
- Built reusable components (Header, TaskForm, TaskList) that pass data via props and practiced one-way data flow by lifting state to a parent and passing callbacks down.
- Managed application state with the useState hook and handled user events including form submissions, checkbox toggles, and button clicks.
- Implemented conditional rendering and list rendering with .map() to build a dynamic UI that responds to state changes in real time.
- Secret Mission: Added task filtering using derived state, a React best practice that computes values from existing state instead of duplicating data.
Ready to quiz yourself?