Build a To-Do App with Express and Vanilla JavaScript

Computer Science

Build a To-Do App with Express and Vanilla JavaScript

Node.jsnpmExpress

Build a To-Do App with Express and Vanilla JavaScript

You will turn your existing web skills into a small full-stack to-do app. Starting with a familiar semantic page and focused CSS, you will add an Express server, connect the browser to a tiny task API, and finish with a working app you can run locally and demonstrate.

4 modules10 lessonsComputer ScienceNode.jsnpmExpressHTMLCSSVanilla JavaScriptFetch API

What you learn by building this

  • Create and run a minimal Node.js project with Express
  • Serve a semantic to-do page and static assets from an Express server
  • Design and implement a small in-memory task API
  • Use fetch, DOM events, and rendering functions to keep the interface synchronized with server data
  • Handle empty states, invalid input, loading states, and failed requests clearly

Learning Journey

1

Shape the app before the server

2 lessons

Use the learner's existing HTML and CSS confidence to create the complete interface contract first. The module stands alone so the reference app is not needed while the backend is still being built.

2

Give the page an Express home

2 lessons

Add one new layer at a time: first a minimal Node project and server, then static-file serving. By the end, the familiar page runs from Express rather than from a file preview.

3

Build the tiny task API

3 lessons

Keep the backend deliberately small and understandable: an in-memory array, JSON responses, and four task operations. Each endpoint is added only when the frontend is ready to use it.

4

Connect the browser and polish the finish

3 lessons

Replace the static page's placeholder behavior with a small vanilla JavaScript client. The finished project will visibly respond to every task action and explain what is happening when the server is slow or returns an error.

Public lesson

Map the task flow and page structure

Lesson 3-1: Give the task app a clear shape

Your HTML work already gives you a good foundation: meaningful headings, sections, labels, and lists. We’ll bring those habits into React now.

For this lesson, keep everything in memory. The app only needs to show how the interface behaves; saving data to a backend comes later.

Tasks

1. Map the interactions first

Before opening your editor, write down the small set of actions this app supports:

  • Type a task
  • Submit a task
  • Mark a task complete
  • Remove a task
  • Clear completed tasks

Now map what the user can see after each action:

The error is not a separate kind of task list. It is feedback shown alongside whichever list state you are currently in.

Create a short note in your project or on paper with these four states:

StateWhat the user sees
EmptyAn explanation that there are no tasks yet
PopulatedOne or more task rows
CompletedEvery visible task is complete, with a completed status
ErrorA useful message when the submitted text is blank

2. Decide the page structure

Use this structure as your plan:

main
├── header
│   ├── h1
│   └── introductory paragraph
├── section: add a task
│   └── form
│       ├── label
│       ├── text input
│       └── submit button
├── section: task list
│   ├── heading
│   ├── live status text
│   ├── empty message OR unordered list
│   │   └── list item
│   │       ├── checkbox and label
│   │       └── remove button
│   └── clear-completed button

Use a real <form> for adding tasks. That gives the Enter key the expected behavior and keeps the interface usable without relying on a click.

Use an unordered list for tasks because the tasks are a collection of peer items. Avoid using <div> elements for every part of the page when a more meaningful element exists.

Tasks

3. Create the React state and semantic shell

Open your main component, probably src/App.jsx. Keep this in one component for now so you can focus on the state transitions before extracting smaller components.

Replace the existing demo content with this scaffold. The missing parts are yours to write.

import { useState } from "react";

export default function App() {
  const [draft, setDraft] = useState("");
  const [tasks, setTasks] = useState([]);
  const [error, setError] = useState("");

  const completedCount = tasks.filter((task) => task.completed).length;
  const openCount = tasks.length - completedCount;

  function handleSubmit(event) {
    event.preventDefault();

    // TODO:
    // 1. Reject input that is empty after trimming.
    // 2. Set an error message without adding a task.
    // 3. For valid input, add a task with:
    //    - a unique id
    //    - the trimmed text
    //    - completed: false
    // 4. Clear the input and any old error.
  }

  function toggleTask(taskId) {
    // TODO: update only the task whose id matches taskId
  }

  function removeTask(taskId) {
    // TODO: create a new list without the matching task
  }

  function clearCompleted() {
    // TODO: keep only tasks whose completed value is false
  }

  return (
    <main>
      <header>
        <h1>Task list</h1>
        <p>Keep a short list of things you want to finish.</p>
      </header>

      <section aria-labelledby="add-task-heading">
        <h2 id="add-task-heading">Add a task</h2>

        <form onSubmit={handleSubmit}>
          <label htmlFor="task-input">Task description</label>
          <input
            id="task-input"
            name="task"
            type="text"
            value={draft}
            onChange={(event) => {
              setDraft(event.target.value);
              // TODO: clear the old error when the learner starts editing
            }}
          />
          <button type="submit">Add task</button>
        </form>

        {error && (
          <p role="alert">{error}</p>
        )}
      </section>

      <section aria-labelledby="task-list-heading">
        <h2 id="task-list-heading">Your tasks</h2>

        <p aria-live="polite">
          {/* TODO: describe the current count, including the completed state */}
        </p>

        {tasks.length === 0 ? (
          <p>
            {/* TODO: write the empty-state message */}
          </p>
        ) : (
          <ul>
            {tasks.map((task) => (
              <li key={task.id}>
                <label>
                  <input
                    type="checkbox"
                    checked={task.completed}
                    onChange={() => toggleTask(task.id)}
                  />
                  <span>{task.text}</span>
                </label>

                <button
                  type="button"
                  onClick={() => removeTask(task.id)}
                >
                  Remove
                </button>
              </li>
            ))}
          </ul>
        )}

        <button type="button" onClick={clearCompleted}>
          Clear completed
        </button>
      </section>
    </main>
  );
}

For the unique ID, use a value that will not collide when two tasks have the same text. A timestamp is sufficient for this practice app. The ID is for React’s key and for finding the correct task; the task text itself is not reliable enough.

Tasks

Checkpoint

Run the app and inspect it in the browser.

At this point, confirm:

  • The page has one clear <h1>.
  • Both sections have their own <h2>.
  • The input has a visible label.
  • The task area contains an unordered list when tasks exist.
  • The buttons have type="button" unless they submit the form.
  • The app does not make a network request.

The interactions will not all work until you fill the TODOs. That is expected.

Tasks

4. Implement the add-task transition

Complete handleSubmit.

Use this order:

  1. Prevent the browser’s normal form navigation.
  2. Create a trimmed version of draft.
  3. If it is empty, set an error and return.
  4. Otherwise, add a new task to the existing array.
  5. Reset the draft and error.

When adding to an array in React, create a new array rather than modifying tasks directly. The new array is what tells React that the state changed.

A useful shape for one task is:

{
  id: /* your unique value */,
  text: /* trimmed draft */,
  completed: false
}

Tasks

Checkpoint

In the browser:

  1. Submit only spaces.
  2. Confirm an alert or error message appears.
  3. Confirm no task row is added.
  4. Type a real task and press Enter.
  5. Confirm the task appears in the list.
  6. Confirm the input becomes empty again.

The error message should be connected to the form visually and semantically. role="alert" makes the feedback available immediately to assistive technology.

Tasks

5. Implement task changes without mutating state

Fill in toggleTask, removeTask, and clearCompleted.

For toggling, use map:

  • Return a changed copy for the matching task.
  • Return the original task for every other task.

For removing and clearing, use filter.

Do not change a task like this:

task.completed = !task.completed;

That changes an existing object in place. Instead, create a new object for the changed task using the spread operator, then replace the task array with the new array.

Tasks

Checkpoint

Create at least three tasks and verify:

  • Checking one task changes only that task.
  • Checking every task produces your completed state.
  • Removing one task leaves the others intact.
  • “Clear completed” removes checked tasks but keeps unchecked tasks.
  • Removing the final task returns the interface to its empty state.

6. Make the status describe all four states

Complete the live status text. It should tell the user what changed without requiring them to count rows.

Your status logic needs to distinguish:

  • No tasks yet
  • Tasks exist and none are complete
  • Some tasks are complete
  • All tasks are complete

You already have the two values needed:

completedCount
openCount

Write the text inside the <p aria-live="polite">. For example, your wording might communicate:

  • “No tasks yet.”
  • “2 tasks remaining.”
  • “1 task remaining; 2 completed.”
  • “All tasks completed.”

Use the singular form when the count is 1. This is a small detail, but it makes status text sound like part of a finished interface rather than debug output.

Tasks

Checkpoint

Use the browser to reach each state and read the status aloud:

  • Empty list
  • One open task
  • Several tasks with a mixture of open and completed
  • All tasks completed

The status should update immediately after checking or removing a task.

Tasks

7. Add only enough styling to make states visible

Your existing stylesheet may already contain the starter styles. If it does not, add a small amount of CSS. The important behavior is that completed tasks look different and the error is easy to find.

:root {
  font-family: system-ui, sans-serif;
  color: #202124;
  background: #f5f6f8;
}

body {
  margin: 0;
}

main {
  width: min(42rem, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

section {
  margin-top: 2rem;
  padding: 1.25rem;
  background: white;
  border: 1px solid #d9dce1;
  border-radius: 0.5rem;
}

form {
  display: grid;
  gap: 0.5rem;
}

ul {
  display: grid;
  gap: 0.75rem;
  padding: 0;
  list-style: none;
}

li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

label {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

[role="alert"] {
  color: #a12622;
}

/* TODO:
   Add a class or data attribute for completed task text,
   then style it with a line-through and lower emphasis.
*/

To style only completed text, add a class conditionally to the <span> in your task row. The class should be present when task.completed is true and absent otherwise.

Tasks

Final check

Test the complete flow in this order:

  1. Load the page: the empty state is visible.
  2. Submit a blank task: the error state is visible.
  3. Add two valid tasks: the populated state is visible.
  4. Complete one task: its text changes and the live status updates.
  5. Complete the second task: the completed state is visible.
  6. Clear completed: the app returns to the empty state.
  7. Reload the page: the list resets, confirming that no backend or persistence has been introduced yet.

Your finished page should have a semantic structure, predictable state transitions, and controls that match the actions the user can take.

Course Outline

4 modules · 10 lessons

Shape the app before the server

Give the page an Express home

Build the tiny task API

Connect the browser and polish the finish

Learn by building your own version.

Remix this public project to open the workspace, follow the guided build, and let the AI mentor teach you through the work instead of doing it for you.