EDUCATIONOverviewStudentsTeachersSchoolsCurriculumProject Library
GUIDED PROJECT·Beginner·~6 hours·13 min read

Build a Todo App

Build the classic to-do app — but done properly, with clean state and real persistence so your tasks survive a refresh. You'll manage a list in state, render it to the DOM, keep the screen in sync as the data changes, and save it to the browser so nothing is lost.

Open in Cartara Classroom →

WHAT YOU'LL WALK AWAY WITH
  • A to-do app that adds, completes, and deletes tasks — and remembers them after a refresh
  • A solid mental model of state as the single source of truth your UI renders from
  • Rendering a list from data, and keeping the screen in sync as that data changes
  • Persistence with localStorage — your first taste of data that outlives the page
TIER
Beginner
TIME
~6 hours
SKILLS
State, lists, persistence basics
STACK
JavaScript, localStorage
PORTFOLIO
The classic rite of passage, done properly
REQUIRES
None

The to-do app is the classic rite of passage, and it's earned that status: it's the smallest project that forces you to hold state — a list that changes as the user adds, completes, and removes things — and keep the screen honest about it. That one problem, "the data changed, now make the UI match," is the beating heart of nearly every app you'll ever build.

This is a true early build — there's no prerequisite. If you can edit an HTML file and read an error in the console, you're ready. The one honest scope note: we're building this with plain JavaScript on purpose, so you feel why the render-from-state pattern matters before a framework does it for you. It's a little more typing than a framework would need, and that's the point.

Info

Estimated time: ~6 hours. The code is short — well under a hundred lines. The time goes into one idea worth sitting with: the difference between changing the page directly and changing your data and re-rendering. Rush it and you get a to-do list you can't extend. Go slow and you get the mental model every future project reuses.

What you're building

A single page. An input and an "Add" button at the top, a list of tasks below, each with a checkbox and a delete button, and a small line at the bottom telling you how many are left. Add a task and it appears; check it off and it gets a strikethrough; delete it and it's gone; refresh the page and everything is still there.

Stack: vanilla JavaScript and the browser's localStorage — no framework, no build step, just an index.html, a little CSS, and one JavaScript file. Why no React? Because React's whole job is to render your UI from state automatically, and if you start there you never see the problem it solves. Building the loop by hand once — change the array, redraw the list — means that when you do pick up a framework, you'll recognize exactly what it's doing for you instead of treating it as magic.

Milestone 0 — The layout: an input, an add button, and an empty list

Create three files in a folder:

todo-app/
  index.html
  style.css
  app.js

A minimal index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Todo</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main>
      <h1>Todo</h1>
      <form id="add-form">
        <input id="new-todo" placeholder="What needs doing?" />
        <button type="submit">Add</button>
      </form>
      <ul id="list"></ul>
      <p id="count"></p>
    </main>
    <script src="app.js"></script>
  </body>
</html>

Open it in your browser. Nothing works yet — the list is empty and the button does nothing. That's fine. The important move here is that the <ul id="list"> is empty in the HTML. You are not going to write task rows in HTML by hand. Your JavaScript will fill that list, every time, from your data. The HTML is just the stage; the JavaScript is the whole play. Keeping the list empty in the markup is the first commitment to the pattern the rest of this build is about.

Milestone 1 — State: the todos array as the single source of truth

Before any buttons work, decide where the truth lives. In app.js, the entire app is one array:

// The single source of truth. Everything the user sees is derived from this.
let todos = [
  // { id: 1, text: "Buy milk", done: false }
];

That's it — that's the state. Each todo is a plain object with three fields: an id so you can find it again, the text the user typed, and a done flag. The whole app is a function of this array.

Sit with this, because it's the central idea of the project. There are two ways to build a to-do app. The tempting one: when the user clicks "Add," you immediately create a <li> and append it to the page; when they click delete, you find that <li> and remove it. The DOM becomes your data. That works for about ten minutes — then you add a "clear completed" button, or a filter, or a count, and now three different places all have to remember to update the page in the same way, and they drift out of sync.

The pattern this build uses instead: the array is the truth, and the screen is just a picture of it. Adding a task means pushing to the array and redrawing. Deleting means removing from the array and redrawing. The DOM never holds anything the array doesn't. That's the render-from-state pattern, and it's the exact idea React was built to formalize — you're learning it here in plain JavaScript so you'll actually understand it later.

Milestone 2 — Render from state: one function that redraws the list from the array

Here's the function that everything else calls. It clears the list and rebuilds it from todos:

const list = document.getElementById("list");

function render() {
  list.innerHTML = ""; // wipe the screen...

  for (const todo of todos) {
    const li = document.createElement("li");

    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = todo.done;

    const span = document.createElement("span");
    span.textContent = todo.text;
    if (todo.done) span.style.textDecoration = "line-through";

    const del = document.createElement("button");
    del.textContent = "Delete";

    li.append(checkbox, span, del);
    list.append(li);
  }
}

render(); // draw once at startup (the empty state, for now)

Why one function, and why does it wipe everything and start over? Because a single render() that always rebuilds the list from the current array can never disagree with your data — there's exactly one place that touches the screen, and it reads straight from the source of truth. You don't hunt down individual rows to update; you change the array and call render(). It feels almost wasteful to throw the whole list away and rebuild it, but for a list this size it's instant, and it buys you something valuable: the screen is always correct by construction. (Rebuilding everything on every change is exactly the naive-but-correct thing frameworks later optimize with a virtual DOM — but you don't need that yet, and understanding why the simple version is safe comes first.)

Notice too that render() reads todo.done to decide the strikethrough. The array says done; the screen shows done. Nothing else decides it.

Milestone 3 — Add, delete, and toggle complete

Now wire the actions. The rule for every one of them is identical: change the array, then call render(). Never touch the DOM directly.

Adding, on form submit:

document.getElementById("add-form").addEventListener("submit", (event) => {
  event.preventDefault();
  const input = document.getElementById("new-todo");
  const text = input.value.trim();
  if (!text) return;

  todos.push({ id: Date.now(), text, done: false });
  input.value = "";
  render();
});

Delete and toggle need to know which todo was clicked. That's what the id is for. Give each checkbox and delete button its todo's id, then handle the events inside render() as you build each row:

// inside the for-loop in render(), after creating the elements:
checkbox.addEventListener("change", () => {
  todos = todos.map((t) =>
    t.id === todo.id ? { ...t, done: !t.done } : t
  );
  render();
});

del.addEventListener("click", () => {
  todos = todos.filter((t) => t.id !== todo.id);
  render();
});

What to understand here: delete uses filter to build a new array without that todo; toggle uses map to build a new array with one todo's done flipped. In both cases you replace todos and re-render — you never reach into the page to strike out a line or remove a row yourself. The screen updates because the data changed and render() ran, not because you edited the DOM. Once this clicks, you have the whole pattern: actions mutate state, one function draws state, and the two never argue.

Heads up

The classic bug in this pattern is forgetting to call render() after you change the array. The data is now correct, the screen is stale, and it looks like your delete button is broken — but the todo really is gone from todos; you just never redrew. When a change "doesn't work" but the logic looks right, check that the very next thing you did was re-render. Understanding why — the DOM has no idea your array changed unless you tell it — is the lesson; the fix is one line.

Milestone 4 — Persistence: saving to and loading from localStorage

Refresh the page right now and everything vanishes — todos starts empty every load. Real apps remember. The browser gives you localStorage, a tiny key-value store that survives refreshes and restarts. Save after every change and load once at startup:

function save() {
  localStorage.setItem("todos", JSON.stringify(todos));
}

function load() {
  const stored = localStorage.getItem("todos");
  todos = stored ? JSON.parse(stored) : [];
}

Call load() before your first render(), and call save() in every action — right alongside each render(). A clean way is to save inside render() itself, so "the screen is up to date" and "the data is persisted" always happen together.

What to understand: localStorage only stores strings. Your todos is an array of objects, so you can't hand it over directly — JSON.stringify turns it into a string on the way in, and JSON.parse turns that string back into a real array on the way out. This pair, stringify to store and parse to read, is how structured data moves through any text-only storage, and you'll see it again with APIs and files.

Heads up

Two real gotchas live here. First, the first-run case: on a brand-new browser, localStorage.getItem("todos") returns null, and JSON.parse(null) does not throw — it quietly gives you null, and your app crashes the moment it tries to loop over it. That's why load() checks stored ? ... : [] — no stored value means start with an empty array, not null. Second, if you ever hand-edit the stored string and it becomes invalid JSON, JSON.parse will throw; wrapping the parse in a try/catch that falls back to [] is the robust version. Handling the empty first run on purpose is the difference between an app that works only for you and one that works for a stranger's first visit.

Milestone 5 — Polish (empty state, a remaining-count) and ship it

Two small touches turn this from an exercise into something you'd actually use, and both fall out of the render-from-state pattern for free — because they're just more things derived from the array.

An empty state so a blank list doesn't look broken, and a remaining count so you can see progress. Add both inside render(), after the loop:

const count = document.getElementById("count");

if (todos.length === 0) {
  list.innerHTML = "<li class='empty'>Nothing yet — add your first task above.</li>";
}

const remaining = todos.filter((t) => !t.done).length;
count.textContent = remaining + " left";

Notice how effortless this is: the count is just todos.filter(...).length, computed fresh on every render. You never store the count separately or remember to increment it — it's derived from the array each time you draw. That's the quiet payoff of a single source of truth: new features are usually just new ways of reading the same state, and they can't fall out of sync because there's nothing separate to keep in sync.

Now put it on the internet. Because it's just static files, deploying is nearly free:

  1. Push the folder to a GitHub repo.
  2. Turn on GitHub Pages (repo Settings → Pages → deploy from your main branch), or drag the folder onto Netlify. Either gives you a public URL in minutes.
  3. Open the live link, add a few tasks, and refresh — they're still there.

Commit it. This is the natural checkpoint to run Cartara against your repo (see the last section).

Stuck? Study after you've built your own

When something won't click, reading real code helps more than another tutorial — but the order matters. Build your own version first, then read this, so you can see the patterns you reinvented rather than copying an answer you skipped:

  • tastejs/todomvc — the same to-do app built in dozens of frameworks against one shared spec. After yours works, read the plain vanilla-JavaScript version and compare: you'll spot the exact render-from-state loop you just wrote, and seeing the framework versions side by side shows you what each one automates.

Reading a finished implementation before you've struggled with the problem teaches you what the code is, not why it's that way. Struggle first; the reading pays off far more.

Build it with Cartara

Here's the honest part. You could get this working by pasting each block above and never quite grasping why the array is the source of truth, why every action ends in a render(), or why JSON.parse(null) bites on the first load. It would run. You'd also be unable to extend it, debug it, or answer "what happens if you forget to re-render?" in a review — and increasingly, an AI assistant will have written parts of it for you, which makes that gap invisible until it bites.

That gap is exactly what Cartara exists to close. As you build:

  • Point Cartara at your repo. It watches the code that lands — including anything an AI assistant generated — and turns each meaningful change into a checkpoint for understanding, not just a commit.
  • Explain your changes back. When you add the render loop or the localStorage layer, Cartara asks you the why and the trade-offs — the same questions a reviewer or interviewer would.
  • See your comprehension, not just your output. You get an honest signal on which parts of your own first app you truly understand — the state model, the render loop, the persistence — so you can shore up the weak spots before you move on to something harder.

That's the loop this whole library is built around: build something real, then prove to yourself you understand it. The to-do app is the project. The understanding is the point.

Turn the classic first project into understanding you can prove

Point Cartara at your todo-app repo and it captures what you genuinely learned — the honest way to make even a familiar build truly yours.

Join the waitlist

Reference implementations

Production code worth studying — afteryou've built your own version.

tastejs/todomvc
The same to-do app built in dozens of frameworks. After yours works, compare it to the vanilla-JS version to see the patterns you reinvented.
← All projects

Build it with Cartara

Point Cartara at your repo as you build this project. It captures what you actually learned from every change — including the code an AI assistant wrote — so an AI-assisted build becomes understanding you can prove.

Join the waitlist