Build a Task Manager with React and Express

Build a full-stack task manager with a React frontend and Express REST API.

Introduction

30 Second Summary

Every productivity app you use follows the same pattern: a frontend you interact with, and a backend that stores your data. Understanding how these two pieces talk to each other is the core skill behind modern web development.

In this project, you will build a full-stack task manager application from scratch using React for the frontend and Express for the backend. You'll create, complete, and delete tasks through a clean interface that communicates with your own API.

What You'll Build

A full-stack task manager with a Node.js/Express backend API and a React frontend, connected through REST endpoints and the fetch API.

By the end of this project, you'll have:

  • A REST API server built with Express that handles GET, POST, PUT, and DELETE requests for tasks.
  • A React single-page application scaffolded with Vite, featuring reusable components for displaying and managing tasks.
  • A complete frontend-to-backend data flow where your UI fetches, creates, updates, and deletes tasks stored in a JSON file on the server.
  • Secret Mission: Add filtering by status and a search bar to find tasks by title.

Are there any prerequisites?

You should be comfortable with JavaScript fundamentals (variables, functions, arrays, objects) and have a basic understanding of how HTTP requests work. No prior React or Express experience is needed.

Before We Start

Before We Start...

Set Up the Project Structure

A full-stack app has two main parts: a frontend that users interact with and a backend that handles data and logic. Keeping these in separate directories makes the architecture visible and easy to work with.

In this step, you'll create the project folder, set up an Express backend with npm, and scaffold a React frontend with Vite. By the end, both directories will be ready for code.

In this step, get ready to:
  • Create the project folder with separate server and client directories.
  • Initialize the Express backend and install dependencies.
  • Scaffold the React frontend using Vite.
Create the project folder structure

You'll build your project inside a single root folder with two subdirectories: server for the backend API and client for the React frontend.

  • In VS Code, open the integrated terminal by pressing Cmd+` (macOS) or Ctrl+` (Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and create the server directory by running these commands:
mkdir task-manager
cd task-manager
mkdir server
  • Confirm the folders exist by running:
ls

You should see server in the output.

Why separate directories?

This structure mirrors how many production apps are organized. The backend and frontend are independent projects that communicate over HTTP.

Each directory gets its own package.json and node_modules. This means each side can have its own dependencies, scripts, and configuration without conflicts.

Initialize the Express backend

The server directory needs its own Node.js project. You'll create a package.json to define the project's metadata and dependencies.

  • Move into the server directory and initialize a new Node.js project by running these commands:
cd server
npm init -y

You should see a confirmation that package.json was created.

What does npm init -y do?

The npm init command creates a package.json file that tracks your project's name, version, and dependencies. The -y flag accepts all default values so you don't have to answer prompts.

The default package.json needs a few changes. You'll switch it to use modern JavaScript imports and add a start script.

  • Open server/package.json in VS Code by clicking on it in the Explorer sidebar.
  • Select all the content and replace it with the following:
{
  "name": "task-manager-server",
  "version": "1.0.0",
  "type": "module",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  }
}

What does this configuration do?

  • "type": "module" enables ES module syntax (import/export) instead of older CommonJS (require).
  • "main": "server.js" tells Node.js which file to run as the entry point.
  • "start": "node server.js" lets you launch the server with npm start.
  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Install Express and the CORS middleware by running this command in your terminal:
npm install express cors

You should see output confirming that packages were added. Your package.json now has a dependencies section with express and cors listed.

What is CORS?

CORS (Cross-Origin Resource Sharing) is a browser security feature. When your React frontend (running on one port) tries to talk to your Express backend (running on a different port), the browser blocks it by default.

The cors package tells Express to allow these cross-origin requests so your frontend can fetch data from your API.

Seeing permission errors or EACCES?

Make sure you're inside the server directory before running npm install. Run pwd to check your current location. You should see a path ending in task-manager/server.

help me fix npm permission errors

✔️ Awesome, I've got everything!

Great. Double check you've saved package.json and that node_modules appeared in your server folder.

ⓧ I'd like to double check the full code

Your server/package.json should look exactly like this after installing dependencies:

{
  "name": "task-manager-server",
  "version": "1.0.0",
  "type": "module",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "cors": "^2.8.6",
    "express": "^5.1.0"
  }
}
Scaffold the React frontend with Vite

Vite is a modern build tool that creates a fully configured React project in seconds. It handles bundling, hot module replacement, and development server setup for you.

  • Navigate back to the task-manager root folder by running:
cd ..
  • Scaffold the React frontend by running this command:
npm create vite@latest client -- --template react

Seeing a prompt about installing create-vite?

If this is your first time using Vite, npm will ask whether to install the create-vite package. Type y and press Enter to continue.

You should see output confirming that the project was scaffolded in the client directory.

  • Move into the client directory and install its dependencies by running:
cd client
npm install

You should see output listing the installed packages. A node_modules folder now exists inside client.

  • Verify your full project structure by navigating to the root and listing all contents:
cd ..
ls

You should see both server and client directories listed.

Don't see the client folder?

Make sure you ran the Vite scaffold command from the task-manager root folder, not from inside server. Run pwd to check your location. If you're in the wrong place, use cd to navigate to ~/Desktop/task-manager and try again.

help me troubleshoot the Vite scaffold command

  • Open the task-manager folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the task-manager folder on your Desktop, then click Open.

Your VS Code Explorer sidebar should now show both server and client directories with their contents.

Your project structure is ready. Next up, you'll build the Express REST API with endpoints for creating, reading, updating, and deleting tasks.

Build the Express REST API

Your project structure is ready with both server/ and client/ directories set up. Now it's time to build the backend that your React frontend will eventually talk to.

A REST API is a server that responds to HTTP requests with data. Your task manager needs four operations: creating tasks, reading them, updating them, and deleting them. In this step, you will build an Express server that handles all four.

In this step, get ready to:
  • Create an Express server with JSON parsing and CORS middleware.
  • Add a JSON file-based data layer for reading and writing tasks.
  • Implement CRUD route handlers for GET, POST, PUT, and DELETE.
Set up the Express server with middleware

Every Express app starts with importing the framework, creating an app instance, and attaching middleware. Middleware is code that runs on every incoming request before your route handlers see it.

  • In VS Code, create a new file called server.js inside your server/ directory.
  • Add the following code to set up imports, constants, and middleware:
import express from 'express';
import cors from 'cors';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DATA_FILE = path.join(__dirname, 'tasks.json');

const app = express();
const PORT = 3001;

app.use(cors());
app.use(express.json());

What does this code do?

The first five lines import the libraries you need. express is the web framework, cors allows cross-origin requests, and fs/path handle file operations.

The __filename and __dirname lines recreate those familiar Node.js variables for ES Modules (since "type": "module" in your package.json disables the CommonJS versions).

app.use(cors()) lets the React dev server (port 5173) talk to this server (port 3001) without the browser blocking the request. app.use(express.json()) automatically parses JSON request bodies so your route handlers can read req.body directly.

  • Save server.js.
  • You won't see a change yet. The next chunk adds the data layer that the routes will use.
Add the JSON data layer

Instead of a database, your server stores tasks in a simple JSON file. Two helper functions handle all the reading and writing.

  • Create a file called tasks.json inside the server/ directory with the following content:
[]

Why start with an empty array?

Your server reads this file to load existing tasks. An empty array [] means "no tasks yet" and gives the server valid JSON to parse on its first run.

  • Save tasks.json.
  • Confirm both files exist by checking the server/ folder in VS Code's Explorer sidebar. You should see server.js, tasks.json, package.json, and node_modules.
  • Now add the helper functions to server.js. Place this code below the app.use(express.json()); line:
// Helper functions to read/write tasks
function readTasks() {
  if (!fs.existsSync(DATA_FILE)) {
    fs.writeFileSync(DATA_FILE, JSON.stringify([]));
  }
  const data = fs.readFileSync(DATA_FILE, 'utf-8');
  return JSON.parse(data);
}

function writeTasks(tasks) {
  fs.writeFileSync(DATA_FILE, JSON.stringify(tasks, null, 2));
}

What do these helpers do?

readTasks() checks if tasks.json exists (creating it if not), reads the file, and returns the parsed array. Every route will call this to get the current task list.

writeTasks() takes an array of tasks and writes it back to the file as formatted JSON. The null, 2 arguments add indentation so the file stays human-readable.

  • Save server.js.
  • You won't see a change yet. The next chunk adds route handlers that call these helpers.
Implement the CRUD route handlers

CRUD stands for Create, Read, Update, Delete. Each operation maps to an HTTP method: POST, GET, PUT, and DELETE.

  • Add the GET and POST routes below the writeTasks function in server.js:
// GET /tasks - list all tasks
app.get('/tasks', (req, res) => {
  const tasks = readTasks();
  res.json(tasks);
});

// POST /tasks - create a new task
app.post('/tasks', (req, res) => {
  const tasks = readTasks();
  const newTask = {
    id: Date.now().toString(),
    title: req.body.title,
    priority: req.body.priority || 'medium',
    completed: false,
    createdAt: new Date().toISOString()
  };
  tasks.push(newTask);
  writeTasks(tasks);
  res.status(201).json(newTask);
});

What do these routes do?

GET /tasks reads the JSON file and returns all tasks as an array. This is what the React frontend will call on page load.

POST /tasks creates a new task object with a unique id (timestamp), the submitted title and priority, and defaults completed to false. It saves the updated array and responds with the new task and a 201 status code.

  • Save server.js.
  • You won't see a change yet. The next chunk adds the remaining routes and starts the server.
  • Add the PUT and DELETE routes, plus the server start, below the POST route in server.js:
// PUT /tasks/:id - update a task
app.put('/tasks/:id', (req, res) => {
  const tasks = readTasks();
  const index = tasks.findIndex(t => t.id === req.params.id);
  if (index === -1) {
    return res.status(404).json({ error: 'Task not found' });
  }
  tasks[index] = { ...tasks[index], ...req.body };
  writeTasks(tasks);
  res.json(tasks[index]);
});

// DELETE /tasks/:id - delete a task
app.delete('/tasks/:id', (req, res) => {
  let tasks = readTasks();
  const index = tasks.findIndex(t => t.id === req.params.id);
  if (index === -1) {
    return res.status(404).json({ error: 'Task not found' });
  }
  tasks = tasks.filter(t => t.id !== req.params.id);
  writeTasks(tasks);
  res.status(204).end();
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What do these routes do?

PUT /tasks/:id finds a task by its ID and merges the request body into it using the spread operator. This lets the frontend toggle completed without overwriting other fields.

DELETE /tasks/:id removes a task by filtering it out of the array. It responds with status 204 (No Content) since there is nothing to send back.

app.listen(PORT) starts the server on port 3001 and logs a confirmation message.

  • Save server.js.
  • Open a terminal in VS Code by pressing Ctrl+` (backtick key).
  • Navigate into the server/ directory and start the server by running:
cd server
node server.js
  • You should see this message in your terminal:
Server running on http://localhost:3001
  • Open your browser and visit http://localhost:3001/tasks.
  • You should see [] displayed in the browser. This is your empty tasks array being returned by the GET route.

Don't see the server running message?

  • Make sure your terminal is inside the server/ folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.
  • If you see a syntax error mentioning import, confirm your package.json has "type": "module".
  • If you see a "module not found" error, make sure you ran npm install in the server/ directory during Step 1.

Help me debug my Express server startup error

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and tasks.json, then continue.

ⓧ I'd like to double check the full code

Here is the complete server/server.js file:

import express from 'express';
import cors from 'cors';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DATA_FILE = path.join(__dirname, 'tasks.json');

const app = express();
const PORT = 3001;

app.use(cors());
app.use(express.json());

// Helper functions to read/write tasks
function readTasks() {
  if (!fs.existsSync(DATA_FILE)) {
    fs.writeFileSync(DATA_FILE, JSON.stringify([]));
  }
  const data = fs.readFileSync(DATA_FILE, 'utf-8');
  return JSON.parse(data);
}

function writeTasks(tasks) {
  fs.writeFileSync(DATA_FILE, JSON.stringify(tasks, null, 2));
}

// GET /tasks - list all tasks
app.get('/tasks', (req, res) => {
  const tasks = readTasks();
  res.json(tasks);
});

// POST /tasks - create a new task
app.post('/tasks', (req, res) => {
  const tasks = readTasks();
  const newTask = {
    id: Date.now().toString(),
    title: req.body.title,
    priority: req.body.priority || 'medium',
    completed: false,
    createdAt: new Date().toISOString()
  };
  tasks.push(newTask);
  writeTasks(tasks);
  res.status(201).json(newTask);
});

// PUT /tasks/:id - update a task
app.put('/tasks/:id', (req, res) => {
  const tasks = readTasks();
  const index = tasks.findIndex(t => t.id === req.params.id);
  if (index === -1) {
    return res.status(404).json({ error: 'Task not found' });
  }
  tasks[index] = { ...tasks[index], ...req.body };
  writeTasks(tasks);
  res.json(tasks[index]);
});

// DELETE /tasks/:id - delete a task
app.delete('/tasks/:id', (req, res) => {
  let tasks = readTasks();
  const index = tasks.findIndex(t => t.id === req.params.id);
  if (index === -1) {
    return res.status(404).json({ error: 'Task not found' });
  }
  tasks = tasks.filter(t => t.id !== req.params.id);
  writeTasks(tasks);
  res.status(204).end();
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

And your server/tasks.json file should contain:

[]

Your REST API is live and responding to requests. Next up, you will build the React components that display tasks and let users interact with them.

Build the React Components

Your Express API is ready to serve, create, update, and delete tasks. Now you need a frontend that can talk to it.

In this step, you'll build the React components that make up the task manager UI. Each component handles one job: the App holds state, TaskList renders the list, and TaskItem renders a single task.

In this step, get ready to:
  • Replace the default Vite template with the App component that manages task state.
  • Create a TaskList component that renders each task.
  • Create a TaskItem component with a checkbox, priority badge, and delete button.
Replace the default App.jsx with your task manager component

The Vite template created default demo content in App.jsx and main.jsx. You'll replace that with the real App component that holds your tasks in state and defines the functions for adding, toggling, and deleting them.

  • In VS Code, open client/src/main.jsx from the Explorer sidebar.
  • Select all the existing content and replace it with this code:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

What does this code do?

This is the entry point for your React app. createRoot mounts the App component into the HTML page, and StrictMode enables extra development warnings to help you catch mistakes early.

  • Save main.jsx.
  • Now open client/src/App.jsx and select all the existing demo content.
  • Replace everything with this code:
import { useState, useEffect } from 'react';
import AddTaskForm from './components/AddTaskForm';
import TaskList from './components/TaskList';
import './App.css';

const API_URL = 'http://localhost:3001/tasks';

function App() {
  const [tasks, setTasks] = useState([]);

  useEffect(() => {
    fetchTasks();
  }, []);

  async function fetchTasks() {
    const response = await fetch(API_URL);
    const data = await response.json();
    setTasks(data);
  }

  async function addTask(title, priority) {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, priority })
    });
    const newTask = await response.json();
    setTasks([...tasks, newTask]);
  }

  async function toggleTask(id) {
    const task = tasks.find(t => t.id === id);
    const response = await fetch(`${API_URL}/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ completed: !task.completed })
    });
    const updatedTask = await response.json();
    setTasks(tasks.map(t => t.id === id ? updatedTask : t));
  }

  async function deleteTask(id) {
    await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
    setTasks(tasks.filter(t => t.id !== id));
  }

  return (
    <div className="app">
      <h1>Task Manager</h1>
      <AddTaskForm onAdd={addTask} />
      <TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
    </div>
  );
}

export default App;

What does this code do?

This is the brain of your frontend. useState([]) creates an empty array that holds your tasks. useEffect calls fetchTasks() once when the page loads, pulling existing tasks from your Express API.

The three handler functions (addTask, toggleTask, deleteTask) each call a different API endpoint using fetch, then update the local state so the UI stays in sync without a full page refresh.

The JSX at the bottom renders the AddTaskForm and TaskList components, passing the handler functions down as props.

  • Save App.jsx.

Why does App.jsx import AddTaskForm?

You'll create the AddTaskForm component in the next step. The import will show a warning in your editor for now, but that's expected. Writing the App first lets you see the full picture of how data flows between components.

Seeing red underlines on the imports?

Your editor might flag the AddTaskForm and TaskList imports as errors because those files don't exist yet. This is normal. The warnings disappear once you create those component files in the next substeps.

help me understand why my React imports are showing errors

✔️ Awesome, I've got everything!

Great. Double check you've saved both main.jsx and App.jsx.

ⓧ I'd like to double check the full code

Here is main.jsx:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Here is App.jsx:

import { useState, useEffect } from 'react';
import AddTaskForm from './components/AddTaskForm';
import TaskList from './components/TaskList';
import './App.css';

const API_URL = 'http://localhost:3001/tasks';

function App() {
  const [tasks, setTasks] = useState([]);

  useEffect(() => {
    fetchTasks();
  }, []);

  async function fetchTasks() {
    const response = await fetch(API_URL);
    const data = await response.json();
    setTasks(data);
  }

  async function addTask(title, priority) {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, priority })
    });
    const newTask = await response.json();
    setTasks([...tasks, newTask]);
  }

  async function toggleTask(id) {
    const task = tasks.find(t => t.id === id);
    const response = await fetch(`${API_URL}/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ completed: !task.completed })
    });
    const updatedTask = await response.json();
    setTasks(tasks.map(t => t.id === id ? updatedTask : t));
  }

  async function deleteTask(id) {
    await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
    setTasks(tasks.filter(t => t.id !== id));
  }

  return (
    <div className="app">
      <h1>Task Manager</h1>
      <AddTaskForm onAdd={addTask} />
      <TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
    </div>
  );
}

export default App;
Create the TaskList component

React apps are built from small, focused components. TaskList receives the array of tasks as a prop and maps over them to render individual TaskItem components.

  • Create a new folder called components inside client/src/ by running this in the terminal:
mkdir client/src/components
  • Create a new file called TaskList.jsx inside client/src/components/ by running:
touch client/src/components/TaskList.jsx
  • Confirm the file was created by running:
ls client/src/components/

You should see TaskList.jsx in the output.

  • Open client/src/components/TaskList.jsx in VS Code and paste this code:
import TaskItem from './TaskItem';

function TaskList({ tasks, onToggle, onDelete }) {
  if (tasks.length === 0) {
    return <p className="empty-state">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?

TaskList handles two states. If there are no tasks, it shows a friendly "No tasks yet" message. If tasks exist, it maps over the array and renders a TaskItem for each one.

The key={task.id} prop tells React how to track each item in the list. This helps React update only the items that changed instead of re-rendering the entire list.

  • Save TaskList.jsx.

Terminal says the folder already exists?

If you see an error when running mkdir, the components folder may already exist from a previous attempt. That's fine. Continue to the next command.

help me create the components folder for my React project

✔️ Awesome, I've got everything!

Great. Make sure you've saved TaskList.jsx.

ⓧ I'd like to double check the full code

import TaskItem from './TaskItem';

function TaskList({ tasks, onToggle, onDelete }) {
  if (tasks.length === 0) {
    return <p className="empty-state">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;
Create the TaskItem component

Each task in the list needs a checkbox for completion, a label showing the title, a priority badge, and a delete button. The TaskItem component packages all of that into one reusable piece.

  • Create a new file called TaskItem.jsx inside client/src/components/ by running:
touch client/src/components/TaskItem.jsx
  • Open client/src/components/TaskItem.jsx in VS Code and paste this 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-title">{task.title}</span>
      <span className={`priority-badge ${task.priority}`}>
        {task.priority}
      </span>
      <button className="delete-btn" onClick={() => onDelete(task.id)}>
        ✕
      </button>
    </li>
  );
}

export default TaskItem;

What does this code do?

TaskItem renders one task as a list item. The className uses a template literal to conditionally add the completed class when the task is done. This lets you style completed tasks differently with CSS later.

The checkbox calls onToggle(task.id) when clicked, which triggers the toggleTask function in App. The delete button works the same way, calling onDelete(task.id) to remove the task.

The priority badge displays the task's priority level (high, medium, or low) and adds it as a CSS class for color-coding.

  • Save TaskItem.jsx.
  • Confirm both component files exist by running:
ls client/src/components/

You should see both TaskItem.jsx and TaskList.jsx in the output.

Don't see both files?

Make sure you ran the touch commands from your project's root folder (the task-manager directory). If you're inside a different directory, the files may have been created in the wrong location.

help me find where my component files were created

✔️ Awesome, I've got everything!

Great. Make sure you've saved TaskItem.jsx.

ⓧ 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-title">{task.title}</span>
      <span className={`priority-badge ${task.priority}`}>
        {task.priority}
      </span>
      <button className="delete-btn" onClick={() => onDelete(task.id)}>
        ✕
      </button>
    </li>
  );
}

export default TaskItem;

Your component structure is in place. Next up, you'll create the AddTaskForm component and verify that everything works together by running the app.

Add the Task Form and Connect to the API

In the previous step, you built the display side of your app. TaskList renders tasks, and TaskItem handles each individual task with a checkbox, priority badge, and delete button.

There's one component missing: the form that lets users actually create new tasks. In this step, you'll build the AddTaskForm component and then trace how React's useEffect hook and fetch calls tie the frontend to the Express API.

In this step, get ready to:
  • Create the AddTaskForm component with title and priority inputs.
  • Understand how useEffect fetches tasks from the API on page load.
  • Trace how the create, toggle, and delete handlers call the API.
Create the AddTaskForm component

Your App.jsx already renders <AddTaskForm onAdd={addTask} /> in its JSX, but the file doesn't exist yet. That's why your browser shows an import error. Let's create it now.

  • Create a new file called AddTaskForm.jsx inside client/src/components/.
  • Add the following code to AddTaskForm.jsx:
import { useState } from 'react';

function AddTaskForm({ onAdd }) {
  // Track the text input and priority dropdown values
  const [title, setTitle] = useState('');
  const [priority, setPriority] = useState('medium');

  function handleSubmit(e) {
    // Prevent the browser from refreshing the page
    e.preventDefault();
    // Ignore empty submissions
    if (!title.trim()) return;
    // Call the parent's addTask function with the form values
    onAdd(title.trim(), priority);
    // Reset the form for the next task
    setTitle('');
    setPriority('medium');
  }

  return (
    <form className="add-task-form" onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Add a new task..."
      />
      <select value={priority} onChange={(e) => setPriority(e.target.value)}>
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>
      <button type="submit">Add</button>
    </form>
  );
}

export default AddTaskForm;

What does this code do?

This component manages its own local state for form inputs, then delegates the actual task creation to the parent component through the onAdd prop.

  • useState('') creates a controlled input. React owns the input's value, so it stays in sync with component state.
  • e.preventDefault() stops the default form submission behavior (which would reload the page).
  • After calling onAdd, the form resets both fields so the user can immediately type another task.
  • Save AddTaskForm.jsx.
  • If your Vite dev server is running, check the browser. The import error should disappear, and you should see a text input, a dropdown, and an Add button rendered on the page (unstyled for now).

Still seeing an error in the browser?

  • Check that the filename is exactly AddTaskForm.jsx (capital A, T, F). The import in App.jsx is case-sensitive.
  • Make sure the file is inside client/src/components/, not in client/src/ or anywhere else.
  • Confirm you have export default AddTaskForm; at the bottom of the file.

Why can't React find my AddTaskForm component?

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { useState } from 'react';

function AddTaskForm({ onAdd }) {
  const [title, setTitle] = useState('');
  const [priority, setPriority] = useState('medium');

  function handleSubmit(e) {
    e.preventDefault();
    if (!title.trim()) return;
    onAdd(title.trim(), priority);
    setTitle('');
    setPriority('medium');
  }

  return (
    <form className="add-task-form" onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Add a new task..."
      />
      <select value={priority} onChange={(e) => setPriority(e.target.value)}>
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>
      <button type="submit">Add</button>
    </form>
  );
}

export default AddTaskForm;
Trace how useEffect fetches tasks on page load

Now that every component exists, let's trace how data flows when the app first loads. Open App.jsx and look at the top of the App function.

How does useEffect connect React to the API?

React's useEffect hook runs code after the component renders. The empty dependency array [] means it only runs once, on the first render.

Here's the sequence that happens when a user opens your app:

  • React renders the App component with an empty tasks array.
  • useEffect fires and calls fetchTasks().
  • fetchTasks sends a GET request to http://localhost:3001/tasks.
  • The Express server reads tasks.json and returns the array.
  • setTasks(data) updates state, triggering a re-render with the loaded tasks.
Trace how the handlers call the API

Your App.jsx defines three handler functions: addTask, toggleTask, and deleteTask. Each one calls the REST API and then updates React state with the result.

How do the handlers map to API endpoints?

  • addTask sends a POST to /tasks with the title and priority from AddTaskForm. The server responds with the new task object (including its generated id), and React appends it to the array.
  • toggleTask sends a PUT to /tasks/:id with the flipped completed value. The server merges the change and returns the updated task.
  • deleteTask sends a DELETE to /tasks/:id. After the server confirms removal, React filters the task out of state.

This pattern keeps the frontend and backend in sync. Every user action hits the API first, then updates the UI only after the server confirms success.

Why update state after the API call, not before?

If you updated React state before the server responded, the UI could show a task that failed to save. By waiting for the API response, you guarantee the UI only displays data the server actually stored.

Every component is in place and the data flow is wired end to end. Next up, you'll add styling and run both servers together to see the full app in action.

Style and Test the Full Application

Your React components and Express API are fully wired together. The app works, but right now it looks like unstyled HTML.

In this step, you will replace the default Vite styles with custom CSS that makes the task manager look polished. Then you will start both servers and verify the complete create, toggle, and delete workflow end-to-end.

In this step, get ready to:
  • Add custom CSS for the task list, form, and completion states.
  • Start the Express and Vite dev servers simultaneously.
  • Test the full CRUD workflow and verify data persistence.
Add custom styles to the application

The App.css file currently has default Vite template styles that do not match the components you built. You will replace the entire file with custom styles for the task manager.

  • In VS Code, open client/src/App.css.
  • Select all the existing content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
  • Add the global reset, body styles, and form layout by pasting this CSS:
/* Global reset */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

/* Base page styles */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  color: #333;
  line-height: 1.6;
}

/* Main app container */
.app {
  max-width: 600px;
  margin: 2rem auto;
  padding: 2rem;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.app h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  color: #1a1a2e;
}

/* Form layout */
.add-task-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}

.add-task-form input[type="text"] {
  flex: 1;
  padding: 0.5rem 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.add-task-form select {
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.add-task-form button {
  padding: 0.5rem 1rem;
  background-color: #1a1a2e;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

.add-task-form button:hover {
  background-color: #16213e;
}

What does this CSS do?

The global reset removes default browser margins and padding so your layout is consistent. The body styles set a light grey background with a clean sans-serif font.

The .app class centers everything in a 600px white card with a subtle shadow. The .add-task-form uses flexbox to lay the input, select, and button side by side.

  • Directly below the form styles you just pasted, add the task list and item styles:
/* Task list container */
.task-list {
  list-style: none;
}

/* Individual task row */
.task-item {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  border-bottom: 1px solid #eee;
}

.task-item:last-child {
  border-bottom: none;
}

/* Completed task styling */
.task-item.completed .task-title {
  text-decoration: line-through;
  color: #999;
}

.task-item input[type="checkbox"] {
  width: 1.2rem;
  height: 1.2rem;
  cursor: pointer;
}

.task-title {
  flex: 1;
}

/* Priority badges with color variants */
.priority-badge {
  font-size: 0.75rem;
  padding: 0.15rem 0.5rem;
  border-radius: 12px;
  font-weight: bold;
  text-transform: uppercase;
}

.priority-badge.high {
  background-color: #ffe0e0;
  color: #d32f2f;
}

.priority-badge.medium {
  background-color: #fff3e0;
  color: #f57c00;
}

.priority-badge.low {
  background-color: #e8f5e9;
  color: #388e3c;
}

/* Delete button */
.delete-btn {
  background: none;
  border: none;
  color: #999;
  cursor: pointer;
  font-size: 1.2rem;
  padding: 0.25rem;
}

.delete-btn:hover {
  color: #d32f2f;
}

/* Empty state message */
.empty-state {
  text-align: center;
  padding: 2rem;
  color: #999;
}

What does this CSS do?

Each .task-item is a flex row with a checkbox, title, priority badge, and delete button spaced evenly. When a task is completed, the .completed class adds a line-through effect to the title.

The .priority-badge classes use colour-coded backgrounds so you can spot high, medium, and low priority tasks at a glance.

  • Save App.css.

Styles not looking right?

Make sure you deleted all the original Vite template CSS before pasting. Leftover styles can conflict with your new rules.

Check that both code blocks were pasted in order with no missing closing braces.

Help me figure out why my CSS is not applying correctly.

✔️ Awesome, I've got everything!

Great. Make sure you have saved App.css before moving on.

ⓧ I'd like to double check the full code

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f5f5f5;
  color: #333;
  line-height: 1.6;
}

.app {
  max-width: 600px;
  margin: 2rem auto;
  padding: 2rem;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.app h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  color: #1a1a2e;
}

.add-task-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}

.add-task-form input[type="text"] {
  flex: 1;
  padding: 0.5rem 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.add-task-form select {
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.add-task-form button {
  padding: 0.5rem 1rem;
  background-color: #1a1a2e;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

.add-task-form button:hover {
  background-color: #16213e;
}

.task-list {
  list-style: none;
}

.task-item {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  border-bottom: 1px solid #eee;
}

.task-item:last-child {
  border-bottom: none;
}

.task-item.completed .task-title {
  text-decoration: line-through;
  color: #999;
}

.task-item input[type="checkbox"] {
  width: 1.2rem;
  height: 1.2rem;
  cursor: pointer;
}

.task-title {
  flex: 1;
}

.priority-badge {
  font-size: 0.75rem;
  padding: 0.15rem 0.5rem;
  border-radius: 12px;
  font-weight: bold;
  text-transform: uppercase;
}

.priority-badge.high {
  background-color: #ffe0e0;
  color: #d32f2f;
}

.priority-badge.medium {
  background-color: #fff3e0;
  color: #f57c00;
}

.priority-badge.low {
  background-color: #e8f5e9;
  color: #388e3c;
}

.delete-btn {
  background: none;
  border: none;
  color: #999;
  cursor: pointer;
  font-size: 1.2rem;
  padding: 0.25rem;
}

.delete-btn:hover {
  color: #d32f2f;
}

.empty-state {
  text-align: center;
  padding: 2rem;
  color: #999;
}
Start both servers

Your app needs two processes running at the same time: the Express API server and the Vite dev server. You will use two separate terminal windows for this.

  • In VS Code, open a new terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Navigate to the server directory and start the Express API by running these commands:
cd server
node server.js

You should see Server running on http://localhost:3001 printed in the terminal.

  • Open a second terminal by clicking the + icon in the terminal panel (top right of the terminal area in VS Code).
  • Navigate to the client directory and start the Vite dev server by running these commands:
cd client
npm run dev

You should see output showing Local: http://localhost:5173/ in the terminal.

Why two terminals?

The Express server (port 3001) and the Vite dev server (port 5173) are separate processes. Each one blocks its terminal while running, so you need a terminal per server.

Port already in use?

If you see an error about port 3001 or 5173 being in use, a previous process is still running. Press Ctrl+C in any open terminals to stop old processes, then try again.

Help me fix a port conflict when starting my servers.

Test the full create, toggle, and delete workflow

Both servers are now running. Time to open the app in your browser and verify that every CRUD operation works end-to-end.

  • Open your browser and go to http://localhost:5173.
  • You should see a white card centered on a grey background with the heading "Task Manager", an input field, a priority dropdown, and an Add button.
  • Type Buy groceries in the input field.
  • Select High from the priority dropdown.
  • Click Add.
  • A new task row appears with a checkbox, the title "Buy groceries", a red HIGH badge, and a delete button.
  • Click the checkbox next to "Buy groceries".
  • The title gets a strikethrough effect and turns grey, showing the task is completed.
  • Add a second task: type Read a book with Low priority and click Add.
  • Click the ✕ button on the first task to delete it.

Only "Read a book" should remain in the list.

What is happening behind the scenes?

Every action (add, toggle, delete) sends a fetch request from your React frontend to the Express API. The API reads and writes server/tasks.json on disk, so your data survives page refreshes.

  • Refresh the browser page with Cmd+R (macOS) or Ctrl+R (Windows).
  • Confirm that your "Read a book" task is still there after the refresh.

The task persists because the Express server stored it in tasks.json. When the page reloads, the useEffect hook fires a GET request and fetches the saved tasks from the server.

  • In VS Code, open server/tasks.json to see the raw data the server persisted.

You should see your remaining task stored as a JSON array with id, title, priority, completed, and createdAt fields.

Tasks disappear on refresh?

Make sure your Express server is still running in the first terminal. If you accidentally closed it, navigate back to the server directory and run node server.js again.

Check that server/tasks.json exists and is not empty. If it contains [] after you added tasks, the write operation may have failed due to a file permission issue.

Help me debug why my tasks are not persisting after a page refresh.

Secret mission

Add Filtering and Search

Your task manager works, but finding specific tasks gets harder as the list grows. Can you add filter buttons and a search bar to help users find what they need?

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:

  • Express dev server running on port 3001.
  • Vite dev server running on port 5173.
  • Project folder on your Desktop containing server/ and client/ directories.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your task manager.

  • Your Express server continues serving the API on http://localhost:3001.
  • Your Vite dev server continues serving the React app on http://localhost:5173.
  • Your task data persists in server/tasks.json.

Pause - I'll come back to this later

Shut down the running dev servers to free up the terminal and ports. Your project files stay on disk so you can restart anytime.

  • In the terminal running your Express server, press Ctrl+C to stop it.
  • In the terminal running your Vite dev server, press Ctrl+C to stop it.
  • To restart later, open two terminals in your project folder. Run node server.js in the server/ directory and npm run dev in the client/ directory.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • In the terminal running your Express server, press Ctrl+C to stop it.
  • In the terminal running your Vite dev server, press Ctrl+C to stop it.
  • Delete the project folder by running the command for your operating system.

macOS

  • Remove the project folder by running:
rm -rf ~/Desktop/task-manager

Windows

  • Remove the project folder by running:
rmdir /s /q %USERPROFILE%\Desktop\task-manager

Nice Work!

Nice Work!

You just built a full-stack JavaScript application from scratch. You now have a working task manager with a React frontend, an Express API, and persistent data storage.

Here's what you learned:

  • Built a REST API with Express and four CRUD endpoints for creating, reading, updating, and deleting tasks.
  • Created a React frontend with reusable, composable components like TaskList, TaskItem, and AddTaskForm.
  • Connected frontend to backend using the fetch API and managed application state with React's useState and useEffect hooks.
  • Secret Mission: Added filtering and search functionality to find tasks by status and title.

Ready to quiz yourself?