Build a To-Do List in React With Hooks (The Modern Way)

By Andrew Siemer · August 6, 2026

← All insights

Maisy building a to-do list interface

If you're new to React and want something real to build in an afternoon, a to-do list is the right first project. It touches everything you'll use forever: components, state, props, handling input, and rendering a list. No routing, no data fetching, no build tooling headaches. Just the core loop.

This walkthrough is deliberately modern. The old version of this post used class components and Create React App, both of which are effectively retired now. We've rewritten it for how React actually gets written today: function components, the useState hook, and Vite as the dev server. If you learned React from a tutorial written before 2020, some of this will look cleaner than you remember.

Spin up a project

Create React App is deprecated. Don't use it. Vite is the current default for a plain React app - it's faster and it's what the React docs point you at.

npm create vite@latest todo-react -- --template react
cd todo-react
npm install
npm run dev

That gives you a running dev server, usually at http://localhost:5173. Open src/App.jsx, delete the boilerplate inside the returned markup, and you're ready.

One tool worth installing before you write anything: the React Developer Tools browser extension. Once it's in, your browser devtools get a "Components" tab where you can inspect any component's props and state live. It makes everything that follows easier to debug.

A word on how React thinks

React keeps a lightweight copy of your UI in memory (people call it the virtual DOM) and, when your data changes, works out the smallest set of real DOM updates to make. You don't manually touch the page. You describe what the UI should look like for a given state, and React reconciles the difference.

The unit you describe it in is a component - a function that returns JSX, a syntax that looks like HTML but is really JavaScript. Components are reusable and they can hold their own state. That's the whole model.

Your first component with state

Here's the starting point for App.jsx. The useState hook is how a function component remembers something between renders. It hands you the current value and a function to update it.

import { useState } from "react";
import "./App.css";

function App() {
  const [pendingItem, setPendingItem] = useState("");

  return (
    <div className="wrapper">
      <p>Let's make a to-do list with React.</p>
    </div>
  );
}

export default App;

useState("") says: this component has a piece of state called pendingItem, it starts as an empty string, and setPendingItem is how I change it. React re-renders the component whenever you call that setter.

Capture what the user types

Add a form with a controlled input. "Controlled" means React state is the single source of truth for the input's value - the value comes from state, and every keystroke updates that state.

function App() {
  const [pendingItem, setPendingItem] = useState("");

  return (
    <div className="wrapper">
      <form className="todo-input">
        <input
          className="input"
          type="text"
          value={pendingItem}
          onChange={(e) => setPendingItem(e.target.value)}
          placeholder="Add an item"
        />
        <button type="submit">add</button>
      </form>
    </div>
  );
}

A few JSX quirks to note. You write className, not class. You drop JavaScript into the markup with curly braces. And onChange fires on every keystroke, calling setPendingItem with the input's current text - which re-renders the component and pushes the new value straight back into the field. Open the Components tab in devtools and watch pendingItem change as you type.

Hold the actual list

One input isn't a list. Add a second piece of state - an array - and a submit handler that pushes the pending item onto it.

function App() {
  const [pendingItem, setPendingItem] = useState("");
  const [list, setList] = useState([]);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!pendingItem.trim()) return;
    setList([{ name: pendingItem }, ...list]);
    setPendingItem("");
  };

  return (
    <div className="wrapper">
      <form className="todo-input" onSubmit={handleSubmit}>
        <input
          className="input"
          type="text"
          value={pendingItem}
          onChange={(e) => setPendingItem(e.target.value)}
          placeholder="Add an item"
        />
        <button type="submit">add</button>
      </form>
    </div>
  );
}

e.preventDefault() stops the form from reloading the page. Then we build a brand-new array with the new item at the front, spreading the old items in behind it, and hand that to setList. Never mutate state directly - list.push(...) won't trigger a re-render and will cause you real pain later. Always create a new array or object. The trim() guard just keeps empty items out.

Render the list

Break the list into its own component. It takes the array and the remove handler as props (props are just the arguments a parent passes to a child) and maps each item to a row.

function List({ items, onRemove }) {
  return (
    <ul>
      {items.map((item, index) => (
        <li key={index}>
          {item.name}
          <button className="action" onClick={() => onRemove(index)}>
            x
          </button>
        </li>
      ))}
    </ul>
  );
}

items.map(...) turns each entry into an <li>. This is the part that feels like magic the first time: change the list state and React re-renders exactly the rows that changed, nothing more.

The key prop matters. React uses it to track which item is which across renders. An array index works for a simple demo, but if your list can be reordered or filtered, use something stable and unique - a real id from your data, for example - instead of the index.

Remove items

The delete button already calls onRemove(index). Here's the handler in App. Use filter to build a new array without the item at that index - same rule as before, no mutation.

const handleRemove = (indexToRemove) => {
  setList(list.filter((_, index) => index !== indexToRemove));
};

Note the original version of this tutorial had a subtle bug here: its filter callback did the comparison but never returned it, so nothing actually got removed. Easy mistake, and a good reminder to read what your callbacks return.

Now wire List into App's markup:

<List items={list} onRemove={handleRemove} />

That's the whole app

Add an item, see it render, delete it, watch it disappear. You just used every core React concept - state, props, controlled inputs, list rendering, and events - in about 60 lines.

Where to go next, roughly in order of usefulness: persist the list to localStorage so it survives a refresh, add a "completed" toggle on each item, then pull the list logic into a custom hook once it grows. When you outgrow useState juggling, useReducer is the natural next step.

We build production React (and a lot more) for clients every day - Inventive has been shipping software out of Austin, TX since 2016, veteran-owned, with 100+ products delivered and a 4.9 on Clutch. But everyone starts with a to-do list. Glad you built yours.

enjoyed the read?

LIKE WHAT YOU just read?

Let's talk about what we could build together.