Build a Task Tracker with React
Build a React task tracker app with components, state, and event handling.
Introduction
30 Second Summary
Every productivity app you use has the same core pattern: you add items, check them off, and remove what you no longer need. The interface updates instantly as you interact with it.
In this project, you will build a task tracker app using React that lets you add tasks, mark them complete, and delete them. You will learn how React breaks a user interface into reusable components that respond to user input in real time.
What You'll Build
A single-page task tracker app built with React and Vite where you can type in tasks, complete them with a strikethrough effect, delete them, and see a live count of remaining tasks.
By the end of this project, you'll have:
- A running React app with hot reload so every code change appears instantly in the browser.
- Four custom components that pass data via props and manage state with the useState hook.
- Interactive features including task completion with strikethrough styling, deletion, and a live task counter.
- Secret Mission: add filter buttons (All / Active / Completed) so users can view subsets of their task list.
Are there any prerequisites?
This project assumes you have medium JavaScript experience. You should be comfortable with functions, arrays, objects, and arrow functions.
No prior React experience is needed. You will learn React concepts from scratch as you build.
Set Up Your React Project
Every React app starts with a project scaffold that sets up the build tools, folder structure, and development server for you.
In this step, you will use Vite to generate a fresh React project, explore what it creates, and strip out the demo content so you have a clean canvas to build on.
In this step, get ready to:
- Scaffold a new React project using Vite.
- Explore the generated project structure.
- Clean up boilerplate and confirm hot reload is working.
Scaffold the project
Vite is a modern build tool that creates a ready-to-go project with one command.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (both platforms).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create 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 Vite's project generator.
- task-tracker is the name of the folder it creates.
- --template react tells Vite to set up a React project with JavaScript.
- Confirm the folder was created by running:
ls
You should see task-tracker listed in the output.
Don't see a task-tracker folder?
- Make sure you included the double dash before the template flag. The command needs both the project name and the template specified.
- Check your internet connection. Vite needs to download the template from npm.
Help me troubleshoot the Vite scaffold command.
Now open the project folder in VS Code so you can see all the generated files in the sidebar.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the task-tracker folder, then click Open.
VS Code reloads with the project open. You should see files like package.json, index.html, and a src/ folder in the Explorer sidebar on the left.
- Open a new terminal by pressing Ctrl+` (both platforms).
- Install the project dependencies by running:
npm install
You should see a progress bar and a success message mentioning the number of packages added.
Seeing errors during npm install?
- Make sure your terminal is inside the task-tracker folder. Run pwd to check your current directory.
- Check that Node.js is installed by running node --version. You need version 20 or higher.
Help me fix npm install errors in my Vite project.
- Start the development server by running:
npm run dev
The terminal displays a local URL (typically http://localhost:5173).
- Hold Cmd (macOS) or Ctrl (Windows) and click the URL in the terminal to open it in your browser.
You should see the default Vite + React welcome page with a spinning logo and a counter button.
Don't see the welcome page?
- Make sure the terminal still shows the dev server running. If you accidentally closed it, run npm run dev again.
- Try manually typing http://localhost:5173 into your browser address bar.
Help me get the Vite dev server working.
Why Vite instead of a framework?
React's official docs recommend tools like Next.js for production apps. For learning React fundamentals though, Vite gives you a minimal setup with zero distractions.
No routing, no server-side rendering, no extra abstractions. Just components, state, and props.
Explore the project structure
Before changing anything, take a moment to understand how the generated files connect.
- In the Explorer sidebar, click src/main.jsx to open it.
This is the entry point for your entire app. It mounts the React application into the page.
- Now click src/App.jsx in the sidebar.
This is the root component. Everything you see on the default welcome page is rendered from this single file.
- Click index.html in the sidebar (it is at the project root, not inside src/).
How do these files connect?
The browser loads index.html, which contains a <div id="root">. React uses this empty div as its mounting point.
Then main.jsx grabs that div and renders the App component inside it. Every component you build will be a child of App.
Clean up boilerplate
The scaffold includes demo content (a spinning logo, a counter button, and preset styles) that would get in the way of building your own app.
- Click the src/App.jsx tab to switch back to it.
- Select all the content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Replace it with this simple component:
function App() {
return <h1>Task Tracker</h1>
}
export default App
What does this code do?
- App is a React component. Components are functions that return JSX (HTML-like syntax that describes what to display).
- export default App makes this component available to other files. main.jsx imports it and renders it into the page.
- Save App.jsx with Cmd+S (macOS) or Ctrl+S (Windows).
Check your browser. Thanks to hot reload, it should already show just the text "Task Tracker" with no logo or counter.
Now clear the remaining boilerplate files.
- Click src/App.css in the sidebar to open it.
- Select all content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Save the empty file.
- Click src/index.css in the sidebar.
- Select all content and delete it.
- Save the empty file.
Finally, remove the default logo assets that the scaffold included.
- In the Explorer sidebar, right-click the src/assets folder.
- Click Delete (or Move to Trash on macOS).
- Confirm the deletion when prompted.
Seeing an error in the browser after deleting assets?
- If the browser shows an error, make sure you saved App.jsx with the new code. The old code imported a logo from the assets folder you just deleted.
- Double check that your App.jsx no longer has any import lines referencing images or SVGs.
Help me fix import errors after cleaning up boilerplate.
Refresh your browser. You should see just the text "Task Tracker" on a plain white page with no errors in the console.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/App.jsx at this point:
function App() {
return <h1>Task Tracker</h1>
}
export default App
Your src/App.css and src/index.css should both be completely empty (no content at all).
Your src/main.jsx should be untouched from the scaffold:
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 clean slate is ready. Next up, you will build the TaskForm component that lets users type in new tasks.
Build the TaskForm Component
Your dev server is running and the browser shows "Task Tracker" on a clean page. Now you'll build the first interactive feature: a form component that captures new tasks from the user.
In React, form inputs are "controlled" by component state. This means you track every keystroke in a state variable and decide exactly what happens when the user submits.
In this step, get ready to:
- Create a TaskForm component with a controlled input and form submission handling.
- Wire TaskForm into App with state management for the tasks array.
- Verify that submitting the form adds tasks to state and clears the input.
Create the TaskForm component
Components in React are reusable building blocks. You'll keep each component in its own file inside a src/components/ folder.
- In the VS Code Explorer sidebar, right-click on the src folder.
- Select New Folder and name it components.
- Right-click on the new components folder and select New File.
- Name the file TaskForm.jsx.
- Paste the following code into TaskForm.jsx:
import { useState } from 'react'
function TaskForm({ onAddTask }) {
// Track the current text in the input field
const [text, setText] = useState('')
function handleSubmit(e) {
// Stop the browser from refreshing the page
e.preventDefault()
// Ignore empty submissions
if (text.trim() === '') return
// Send the trimmed text up to the parent component
onAddTask(text.trim())
// Clear the input for the next task
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 Task</button>
</form>
)
}
export default TaskForm
What does this code do?
This component does three things when a user interacts with the form:
- useState('') creates a state variable called text that holds whatever the user has typed so far.
- The onChange on the input updates text on every keystroke, keeping the input "controlled" by React state.
- handleSubmit prevents the default page refresh, checks the input is not blank, calls onAddTask (a function passed down from the parent), and clears the input.
- Save TaskForm.jsx.
You won't see a change in the browser yet. The next substep imports TaskForm into App so it actually renders on the page.
Seeing a red squiggly line in VS Code?
If VS Code underlines the import or useState line, make sure the file extension is .jsx (not .js). The .jsx extension tells the tooling this file contains React markup.
help me fix a red underline in my TaskForm.jsx file
✔️ Awesome, I've got everything!
Great. Make sure you've saved TaskForm.jsx 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"
placeholder="Add a new task..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add Task</button>
</form>
)
}
export default TaskForm
Wire TaskForm into App
TaskForm calls an onAddTask prop when the user submits, but that function doesn't exist yet. You'll define it in App so the parent component owns the task data.
- Switch back to src/App.jsx and replace its entire contents with the following code:
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import './App.css'
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: text,
completed: false,
}
// Add the new task without mutating the existing array
setTasks([...tasks, newTask])
}
return (
<div className="app-container">
<h1>Task Tracker</h1>
<TaskForm onAddTask={addTask} />
</div>
)
}
export default App
What does this code do?
App is now the single owner of the tasks data:
- useState([]) creates an empty array to hold task objects.
- addTask builds a new object with id (a unique timestamp), text (what the user typed), and completed: false.
- setTasks([...tasks, newTask]) creates a brand new array with all existing tasks plus the new one. This immutable pattern is how React knows to re-render.
- Save App.jsx.
- Your browser updates automatically. You should see a text input field and an Add Task button below the Task Tracker heading.
Don't see the input and button?
Make sure both TaskForm.jsx and App.jsx are saved. Check that your import path matches exactly: './components/TaskForm.jsx'.
If you see a blank page, open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for error messages about missing files or typos.
help me fix my TaskForm not showing up in the browser
✔️ Awesome, I've got everything!
Great. Make sure you've saved App.jsx before moving on.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import TaskForm from './components/TaskForm.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])
}
return (
<div className="app-container">
<h1>Task Tracker</h1>
<TaskForm onAddTask={addTask} />
</div>
)
}
export default App
Verify tasks are being added to state
The form renders, but you can't see the tasks yet because there's no list component. You can still confirm everything works by checking that the input clears after submission.
- Click inside the text input in your browser.
- Type Buy groceries and press Enter or click Add Task.
- Confirm the input field clears back to the placeholder text.
- Submit a few more tasks to confirm it works repeatedly.
How do I know tasks are actually stored?
The input clearing proves that handleSubmit ran successfully and called onAddTask (if the text was empty, the function returns early without clearing). You'll see the tasks rendered visually in the next step when you build the TaskList component.
If you want to peek at the state now, install the React Developer Tools browser extension and open the Components tab in your browser DevTools. Select the App component to see the tasks array in the right panel.
Your form is capturing tasks and storing them in App state. Next up, you'll build the TaskList and TaskItem components to display those tasks on the page.
Display Tasks with TaskList and TaskItem
Your task tracker can capture tasks through the form, but those tasks are invisible. They exist in the App state array, yet nothing renders them to the screen.
In this step, you will build two new components that work together to display tasks. TaskItem handles a single task (its checkbox, text, and delete button). TaskList loops over the full array and renders one TaskItem per entry.
In this step, get ready to:
- Create a TaskItem component that renders a single task with a checkbox and delete button.
- Create a TaskList component that maps over the tasks array and renders TaskItems.
- Wire both components into App with toggle and delete handler functions.
Create the TaskItem component
Each task in your list needs the same UI: a checkbox, the task text, and a delete button. A dedicated component handles all three for a single task.
- Create a new file called src/components/TaskItem.jsx and add the following code:
function TaskItem({ task, onToggle, onDelete }) {
return (
<li className={`task-item ${task.completed ? 'completed' : ''}`}>
<label className="task-label">
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span className="task-text">{task.text}</span>
</label>
<button className="delete-btn" onClick={() => onDelete(task.id)}>
Delete
</button>
</li>
)
}
export default TaskItem
What does this code do?
TaskItem receives three props from its parent: the task object, an onToggle function, and an onDelete function.
- The className uses a template literal to add the completed CSS class only when task.completed is true. This is conditional rendering for styles.
- The checkbox's onChange calls onToggle(task.id) so the parent knows which task to flip.
- The delete button's onClick calls onDelete(task.id) so the parent knows which task to remove.
- Save src/components/TaskItem.jsx.
- You will not see a change in the browser yet. The next component (TaskList) will use TaskItem to render tasks on screen.
Seeing a red error in the terminal?
Make sure the file is saved inside the src/components/ folder, not in src/ directly. Also check that the filename ends in .jsx (not .js).
help me fix an error in my TaskItem component
✔️ Awesome, I've got everything!
Great. Make sure you have saved TaskItem.jsx 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' : ''}`}>
<label className="task-label">
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span className="task-text">{task.text}</span>
</label>
<button className="delete-btn" onClick={() => onDelete(task.id)}>
Delete
</button>
</li>
)
}
export default TaskItem
Create the TaskList component
TaskList is the parent that loops over every task and renders a TaskItem for each one. It also handles the empty state when there are no tasks yet.
- Create a new file called src/components/TaskList.jsx and add the following code:
import TaskItem from './TaskItem.jsx'
function TaskList({ tasks, onToggle, onDelete }) {
// Show a message when there are no tasks
if (tasks.length === 0) {
return <p className="empty-message">No tasks yet. Add one above!</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?
- The early if (tasks.length === 0) return is conditional rendering. When the array is empty, React shows the friendly message instead of an empty list.
- tasks.map() is list rendering. It transforms each task object in the array into a TaskItem component on screen.
- key={task.id} gives React a stable identifier for each list item. React uses keys to efficiently update only the items that changed, instead of re-rendering the entire list.
- Save src/components/TaskList.jsx.
- You will not see a change in the browser yet. The next substep wires TaskList into App so it renders on screen.
Seeing an import error?
The import at the top pulls in TaskItem from the same folder. Make sure both TaskItem.jsx and TaskList.jsx live inside src/components/.
help me fix this import error in TaskList
✔️ Awesome, I've got everything!
Great. Make sure you have saved TaskList.jsx before moving on.
ⓧ I'd like to double check the full code
import TaskItem from './TaskItem.jsx'
function TaskList({ tasks, onToggle, onDelete }) {
// Show a message when there are no tasks
if (tasks.length === 0) {
return <p className="empty-message">No tasks yet. Add one above!</p>
}
return (
<ul className="task-list">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
)
}
export default TaskList
Connect TaskList to App with toggle and delete handlers
TaskList and TaskItem are ready, but App does not know about them yet. You need to import TaskList, write the two handler functions it expects, and render it in the JSX.
- In src/App.jsx, find the import section at the top of the file. Add the TaskList import below the TaskForm import so your imports look like this:
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/TaskList.jsx'
import './App.css'
- Next, add the toggleTask and deleteTask functions inside the App function, directly after the addTask function:
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))
}
What do these functions do?
Both functions update state immutably. They never modify the original array. Instead, they create a new array and pass it to setTasks.
- toggleTask uses .map() to produce a new array. For the task with the matching id, it spreads all existing properties and flips completed to its opposite value. Every other task stays unchanged.
- deleteTask uses .filter() to produce a new array that excludes the task with the matching id.
- Finally, update the return block to render TaskList. Find this line:
<TaskForm onAddTask={addTask} />
- Add the following line directly below it:
<TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
What is happening with these props?
You are passing the tasks state array and both handler functions down as props. TaskList passes them further down to each TaskItem. This pattern is called lifting state up. The data lives in App (the shared parent) and flows down to the children that need it.
- Save src/App.jsx.
- Switch to your browser. You should see the message "No tasks yet. Add one above!" below the form.
- Type a task in the input and click Add Task. The task appears in the list with a checkbox on the left and a Delete button on the right.
Tasks not appearing when you submit?
- Check that you imported TaskList at the top of App.jsx (not TaskItem).
- Confirm the <TaskList /> line is inside the return block, after <TaskForm />.
- Make sure the prop names match exactly: tasks, onToggle, and onDelete.
help me debug why my tasks are not appearing
✔️ Awesome, I've got everything!
Great. Double check you have saved App.jsx and can see tasks rendering in the browser.
ⓧ 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 './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))
}
return (
<div className="app-container">
<h1>Task Tracker</h1>
<TaskForm onAddTask={addTask} />
<TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
</div>
)
}
export default App
Your tasks are now visible. Next up, you will make the checkbox and delete button actually work by wiring up the toggle and delete interactions.
Implement Toggle, Delete, and a Task Counter
Your task tracker displays tasks in the browser, but you haven't tested the interactive features yet. The toggle and delete logic exists in your code from the previous step.
In this step, you'll verify that toggling and deleting tasks works correctly. Then you'll add a live counter that shows how many incomplete tasks remain.
In this step, get ready to:
- Verify the toggle functionality flips task completion.
- Verify the delete functionality removes tasks.
- Add a task counter that updates live.
Implement toggle functionality
Your toggleTask function already exists in App.jsx, and TaskItem's checkbox is wired to call it. Time to confirm it actually works.
- Switch back to your browser at http://localhost:5173.
- Add two or three tasks using the form.
- Click the checkbox next to a task.
- Confirm the checkbox fills in (checked state). Click it again to uncheck it.
What does toggleTask do?
When you click a checkbox, React calls onToggle(task.id) which routes back to toggleTask in App.
That function maps over the tasks array and flips the completed boolean for the matching task. Every other task stays unchanged. This is an immutable update because you create a new array instead of modifying the existing one.
Strikethrough styling comes later
You won't see a visual strikethrough yet because the CSS hasn't been added. The checkbox toggling between checked and unchecked confirms the logic works. You'll add the strikethrough style in Step 5.
Implement delete functionality
The deleteTask function is also wired up. Each TaskItem's Delete button calls onDelete(task.id) on click.
- In your browser, click the Delete button next to one of your tasks.
- Confirm the task disappears from the list immediately.
- Delete all remaining tasks and confirm you see the "No tasks yet. Add one above!" empty-state message.
What does deleteTask do?
The deleteTask function uses .filter() to create a new array that excludes the task with the matching id. This is another immutable update. React sees the new array and re-renders the list without that task.
Clicking Delete or the checkbox does nothing?
- Make sure you saved App.jsx, TaskList.jsx, and TaskItem.jsx. Unsaved changes won't appear in the browser.
- Open the browser console (right-click the page, then click Inspect, then the Console tab). Look for red error messages that hint at a typo or prop mismatch.
- Confirm that onToggle and onDelete are passed from App through TaskList to TaskItem. A missing prop at any level breaks the chain.
Why aren't my toggle and delete buttons working in React?
Add a task counter
Right now the user has no way to see at a glance how many tasks still need attention. A live counter solves this by computing the number of incomplete tasks on every render.
- In src/App.jsx, find the deleteTask function's closing brace. Add the following line below it (before the return statement):
const remainingCount = tasks.filter((task) => !task.completed).length
- Next, find the <h1>Task Tracker</h1> line inside the return block. Add this paragraph element directly below it:
<p className="task-counter">
{remainingCount} task{remainingCount !== 1 ? 's' : ''} remaining
</p>
What does this code do?
- remainingCount filters the tasks array to keep only tasks where completed is false, then returns the count with .length.
- The ternary remainingCount !== 1 ? 's' : '' adds an "s" to "task" when the count is anything other than 1. This handles pluralization ("0 tasks", "1 task", "3 tasks").
- Because remainingCount is computed directly from tasks state, React recalculates it automatically whenever a task is added, toggled, or deleted.
- Save src/App.jsx.
- In your browser, add a few tasks. You should see the counter (e.g. "3 tasks remaining") below the heading.
- Check a task's checkbox. The counter should decrease by one.
- Delete a task. The counter should update to reflect the new total.
Counter not updating?
- Make sure remainingCount is placed OUTSIDE the return statement but INSIDE the App function body. It needs to run on every render.
- Confirm you're using curly braces {remainingCount} in the JSX. Without braces, React renders the literal text "remainingCount" instead of the variable's value.
Why isn't my React counter updating when I toggle or delete tasks?
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.jsx and confirmed the counter updates live.
ⓧ I'd like to double check the full code
Here's the complete src/App.jsx file at this point:
import { useState } from 'react'
import TaskForm from './components/TaskForm.jsx'
import TaskList from './components/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-container">
<h1>Task Tracker</h1>
<p className="task-counter">
{remainingCount} task{remainingCount !== 1 ? 's' : ''} remaining
</p>
<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 with a live counter that updates instantly. Next up, you'll add CSS styling to make the app look polished and professional.
Style the App
Your task tracker is fully functional. You can add tasks, check them off, and delete them. But right now it looks like unstyled browser defaults.
In this step, you will add CSS to transform the app into a clean, professional interface. Good styling gives users visual feedback that their actions worked.
In this step, get ready to:
- Style the container, heading, and form with a centered layout.
- Style task items with flexbox, completion states, and delete buttons.
- Add global resets and a background colour for final polish.
Style the layout and form
All of the class names are already in your JSX from previous steps. You just need to define what those classes look like.
- In VS Code, open src/App.css (it should be empty).
- Add the container, heading, and counter styles by pasting this code:
/* Center the app and set a clean font */
.app-container {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
/* Style the main heading */
h1 {
margin: 0 0 0.25rem 0;
color: #1a1a2e;
}
/* Muted text for the task counter */
.task-counter {
color: #666;
margin: 0 0 1.5rem 0;
font-size: 0.9rem;
}
What does this code do?
- .app-container centers the entire app on the page with a max width of 500px. The margin: 2rem auto trick centres a block element horizontally.
- h1 removes default spacing so the heading and counter sit close together.
- .task-counter uses a smaller muted font so it feels like a subtitle, not a competing heading.
- Save src/App.css and check your browser.
- You should see the heading and counter centred on a white background with clean spacing.
Still seeing unstyled text?
Make sure your App.jsx still has import './App.css' near the top. Without that import, React does not load the stylesheet.
Also check that you saved the CSS file. Vite picks up changes on save.
Why isn't my App.css being applied in the browser?
- Now add the form styles directly below the code you just pasted:
/* Lay out the form as a row with a gap */
.task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
/* Input takes all available horizontal space */
.task-form input {
flex: 1;
padding: 0.6rem 0.8rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
/* Highlight the border on focus */
.task-form input:focus {
outline: none;
border-color: #4a90d9;
}
/* Blue submit button */
.task-form button {
padding: 0.6rem 1.2rem;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.task-form button:hover {
background-color: #357abd;
}
What does this code do?
- display: flex on .task-form places the input and button side by side in a row.
- flex: 1 on the input tells it to stretch and fill all remaining space after the button takes its fixed width.
- The :focus rule gives the user a visual cue (blue border) when they click into the input.
- The :hover rule darkens the button on mouseover so it feels interactive.
- Save src/App.css and check your browser.
- The input and button should sit side by side. Click into the input to see the blue focus ring. Hover over the button to see it darken.
Input and button stacked vertically?
Check that your .task-form rule has display: flex. Without it, block elements stack vertically by default.
Why is my flexbox layout not working for the form?
Style the task list and items
The list items need structure so the checkbox, text, and delete button align properly. You will also add the visual feedback for completed tasks.
- In src/App.css, add the list and item structure styles below the form styles:
/* Remove default list bullets and spacing */
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
/* Each task is a row with space between label and delete button */
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
/* Checkbox and text sit side by side */
.task-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Make checkbox slightly larger than browser default */
.task-label input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
What does this code do?
- .task-list removes the default bullet points and indentation that browsers add to <ul> elements.
- justify-content: space-between on .task-item pushes the label to the left and the delete button to the right.
- .task-label uses flexbox to keep the checkbox and text aligned on the same baseline.
- Save src/App.css and add a few tasks in the browser.
- Each task should appear as a clean row with the checkbox on the left and a light border separating items.
Still seeing bullet points?
Confirm your .task-list rule includes list-style: none. Also check that the class name in TaskList.jsx is task-list (with a hyphen, not an underscore).
Why are my list bullets still showing in the task list?
- Now add the text styles, completed state, delete button, and empty message below the list styles:
/* Base task text colour */
.task-text {
font-size: 1rem;
color: #1a1a2e;
}
/* Strikethrough and muted colour for completed tasks */
.task-item.completed .task-text {
text-decoration: line-through;
color: #999;
}
/* Small red delete button */
.delete-btn {
padding: 0.3rem 0.7rem;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
}
.delete-btn:hover {
background-color: #c0392b;
}
/* Placeholder when no tasks exist */
.empty-message {
color: #999;
text-align: center;
padding: 2rem 0;
font-style: italic;
}
What does this code do?
- .task-item.completed .task-text targets the text span only when the parent item has the completed class. This is the visual feedback for checking off a task.
- .delete-btn uses a red background to signal a destructive action. The hover state darkens it to confirm the button is clickable.
- .empty-message shows centred italic text when the task list is empty.
- Save src/App.css and test in your browser.
- Add a task, then click its checkbox. The text should show a strikethrough with a muted colour. Click the red delete button to remove it.
Strikethrough not showing when you check a task?
The selector .task-item.completed .task-text requires the completed class to be on the same element as task-item. Open your TaskItem.jsx and confirm the className on the <li> adds completed when task.completed is true.
Why doesn't my completed task get the strikethrough style?
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
.app-container {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
margin: 0 0 0.25rem 0;
color: #1a1a2e;
}
.task-counter {
color: #666;
margin: 0 0 1.5rem 0;
font-size: 0.9rem;
}
.task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.task-form input {
flex: 1;
padding: 0.6rem 0.8rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.task-form input:focus {
outline: none;
border-color: #4a90d9;
}
.task-form button {
padding: 0.6rem 1.2rem;
background-color: #4a90d9;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.task-form button:hover {
background-color: #357abd;
}
.task-list {
list-style: none;
padding: 0;
margin: 0;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.task-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.task-label input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
}
.task-text {
font-size: 1rem;
color: #1a1a2e;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: #999;
}
.delete-btn {
padding: 0.3rem 0.7rem;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
}
.delete-btn:hover {
background-color: #c0392b;
}
.empty-message {
color: #999;
text-align: center;
padding: 2rem 0;
font-style: italic;
}
Add final polish with global resets
Browsers apply their own default styles (margins on the body, inconsistent box sizing). A small global reset in index.css removes those inconsistencies so your layout behaves predictably.
- Open src/index.css (it should be empty) and paste the following global styles:
/* Consistent box model for all elements */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Subtle background and full-height page */
body {
margin: 0;
background-color: #f5f5f5;
min-height: 100vh;
}
What does this code do?
- box-sizing: border-box makes padding and borders count toward an element's total width. Without it, adding padding can break your layouts.
- The body reset removes the default browser margin and sets a light grey background. The white .app-container now stands out against it.
- Save src/index.css and check your browser.
- The page background should now be a subtle grey, with your app content on a white card-like area in the centre.
Background not changing?
Confirm src/main.jsx has import './index.css' at the top. This import loads the global styles for the entire app.
Why isn't my index.css background colour showing?
✔️ Awesome, I've got everything!
Great. Make sure both CSS files are saved before testing.
ⓧ I'd like to double check the full code
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: #f5f5f5;
min-height: 100vh;
}
- Test the full app end-to-end: add a few tasks, check some off, delete one, and confirm everything looks polished.
You should see the task counter update live, completed tasks show a strikethrough, and the empty state message appear when all tasks are removed.
Secret mission
Add Task Filtering
Your task tracker works great, but what happens when you have 20 tasks and want to focus on just the incomplete ones? Add filter buttons (All / Active / Completed) so users can view subsets of their task list.
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 (includes all source files and node_modules dependencies).
- The Vite dev server process running in your terminal.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your task tracker.
- Your dev server stays running at http://localhost:5173 and updates live as you edit files.
- Your project folder remains on your Desktop, ready for you to open in VS Code anytime.
Pause - I'll come back to this later
Stop the dev server to free up your terminal. Your files stay on disk so you can pick up where you left off.
- In the terminal where the dev server is running, press Ctrl+C to stop it.
- When you're ready to resume, open a terminal in the task-tracker folder and run npm run dev to start the server again.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- In the terminal where the dev server is running, press Ctrl+C to stop it.
- Navigate to your Desktop and delete the project folder by running these commands:
macOS
cd ~/Desktop
rm -rf task-tracker
Windows
cd ~\Desktop
Remove-Item -Recurse -Force task-tracker
- Confirm the folder is gone by running ls (macOS) or dir (Windows). You should no longer see task-tracker in the output.
Nice Work!
Nice Work!
You just built a fully functional task tracker app with React. From a blank Vite scaffold to a polished multi-component application, you now have hands-on proof that you can build interactive UIs the way professional frontend developers do.
Here's what you learned:
- Built a multi-component React application with a clear parent-child data flow using props.
- Managed application state with the useState hook and immutable update patterns (spread, map, filter).
- Handled user events (form submit, checkbox change, button click) across components with callback props.
- Secret Mission: Implemented derived state with task filtering (All / Active / Completed).
Ready to quiz yourself?