Building a Todo App with React #1 Getting Started, Add and Delete

7 min read

For those who finished the React Basics course (#1–#15), this is the start of a practical build series. The first project is the Todo app that every framework beginner builds at some point. It looks small, but it’s a great practice topic that naturally weaves in component decomposition, state management, events, forms, list rendering, and persistence — basically all of React’s fundamentals.

We’ll build it in 5 posts, layering on functionality progressively.

  • #1 Getting started, add/delete ← this post
  • #2 Completion toggle and stats
  • #3 Filtering
  • #4 Editing
  • #5 Persistence and wrap-up

Defining the requirements #

Before building anything, it’s a good habit to clearly write down what you’re going to build. Don’t just keep it in your head — jot down even a line or two.

When this series is done, our app will be able to:

  • Enter a new task and add it
  • Display the task list
  • Delete a task
  • Mark tasks as complete (checkbox)
  • Show count of remaining / total
  • Filter by All / Active / Completed
  • Bulk operations (mark all complete, delete completed)
  • Edit a task inline
  • Persist data across page reloads (localStorage)

In this post we only cover add / list / delete. We’ll layer on the rest in later posts.

Designing the component tree #

Before writing code, it’s helpful to sketch how to split components in advance. Don’t get greedy with too-fine slices — start simple, focused on the big picture.

Component tree (#1 baseline)
App
└── TodoApp
    ├── TodoForm        — input form
    └── TodoList        — list container
        └── TodoItem    — individual item (repeated)

Where should state live? The input value (TodoForm) only matters to the form, so put it there, but keep the task list itself in TodoApp. Since the form needs to add items and items need to delete themselves — both touching the same list — the lifting state up pattern from #11 applies naturally.

Starting the project #

If you have a Vite project from React Basics #2 you can reuse it, or create a new one.

If starting fresh
npm create vite@latest todo-app
cd todo-app
npm install
npm run dev

Choose React + JavaScript. After starting, clear the boilerplate and start with an empty App.jsx.

src/App.jsx (initial state)
function App() {
  return <h1>Todo App</h1>;
}

export default App;

Empty out src/App.css and src/index.css too, or keep them minimal. This series uses inline style to keep code simple (in production you’d use CSS Modules, Tailwind, styled-components, etc., but for now we’re focused on the core logic).

Designing the data shape #

What information does each task hold? At minimum:

Task object shape
{
  id: 'unique ID',
  text: 'task text',
  completed: false,
}

We’ll use a UUID generated by crypto.randomUUID() for id. Index keys are an anti-pattern, as we covered in #8.

Building TodoForm #

Let’s start with the input form. It’s a controlled component (#9), and it tells the parent about new items on form submit.

Create src/TodoForm.jsx.

src/TodoForm.jsx
import { useState } from 'react';

function TodoForm({ onAdd }) {
  const [text, setText] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = text.trim();
    if (!trimmed) return;
    onAdd(trimmed);
    setText('');
  }

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Enter a task"
        style={{ flex: 1, padding: '6px' }}
      />
      <button type="submit" disabled={!text.trim()}>Add</button>
    </form>
  );
}

export default TodoForm;

Key points:

  • The text state is only used inside this form, so it lives here
  • onAdd is a callback that informs the parent of “a new item arrived” (passed via props)
  • text.trim() ignores whitespace-only input
  • The input clears after adding
  • The button is disabled when input is empty

Building TodoItem #

The component for an individual item. Start with just text and a delete button (we’ll add the checkbox in #2 and editing in #4).

src/TodoItem.jsx:

src/TodoItem.jsx
function TodoItem({ todo, onDelete }) {
  return (
    <li style={{
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      padding: '8px',
      borderBottom: '1px solid #eee',
    }}>
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)} style={{ marginLeft: '8px' }}>
        Delete
      </button>
    </li>
  );
}

export default TodoItem;

onDelete also comes from the parent. The item itself can’t delete itself (remember the read-only-props principle from #4?) — instead, it asks the parent to “delete the one with my ID.”

Building TodoList #

A container that renders TodoItems. It also handles the empty state with a message (conditional rendering from #7).

src/TodoList.jsx:

src/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos, onDelete }) {
  if (todos.length === 0) {
    return <p style={{ color: '#888' }}>No tasks yet. Add a new one!</p>;
  }

  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onDelete={onDelete} />
      ))}
    </ul>
  );
}

export default TodoList;

Combining in TodoApp #

Now let’s tie everything together. The state (todos) lives here, and the add/delete handlers are defined here and passed down to children.

src/TodoApp.jsx:

src/TodoApp.jsx
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoList from './TodoList';

function TodoApp() {
  const [todos, setTodos] = useState([]);

  function addTodo(text) {
    const newTodo = {
      id: crypto.randomUUID(),
      text,
      completed: false,
    };
    setTodos(prev => [newTodo, ...prev]);
  }

  function deleteTodo(id) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }

  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
      <h1>Todo</h1>
      <TodoForm onAdd={addTodo} />
      <TodoList todos={todos} onDelete={deleteTodo} />
    </div>
  );
}

export default TodoApp;

Key patterns:

  • setTodos(prev => [newTodo, ...prev]) — adds the new item to the top while creating a new array (the immutable update pattern from #5)
  • setTodos(prev => prev.filter(todo => todo.id !== id)) — also a new array for delete
  • We use the functional updater (prev => ...) to safely receive the previous value (covered in #5)

Finally, render TodoApp from App.jsx.

src/App.jsx
import TodoApp from './TodoApp';

function App() {
  return <TodoApp />;
}

export default App;

Save and check in the browser. Type a task, click Add, and it appears at the top; click Delete and it disappears.

Verification checklist #

  • Empty input doesn’t add
  • Whitespace-only input (" ") doesn’t add either
  • New items go to the top (newest first)
  • The same text added twice creates two separate items (different UUIDs)
  • The Delete button only deletes the exact item

If everything works, step 1 is done.

Reviewing the data flow #

Let’s recap the structure we built.

Data flow
TodoApp (todos state)
  ├─ holds addTodo / deleteTodo functions
  ├─ TodoForm
  │   - onAdd={addTodo}    ← informs of new items
  └─ TodoList
      - displays todos
      - onDelete={deleteTodo}    ← delete request
        └─ TodoItem (each item)

The key point: the data (todos) lives in only one place (TodoApp), and every change goes through that place. Neither TodoForm nor TodoItem touches the data directly — they ask “please do this for me” via callbacks. This is what the unidirectional data flow and lifting state up patterns from #11 look like in practice.

Tip
You may wonder where to put component files. For a small project, just placing them flat under src/ is fine; as the codebase grows, gradually organize them into folders like src/components/Todo/.... Building a deep folder structure from the start often makes the code harder to navigate.

Wrap-up #

In this post we took the first steps of a Todo app.

  • Wrote down requirements and sketched the component tree
  • Split responsibilities across TodoForm / TodoItem / TodoList / TodoApp
  • Built a unidirectional flow: state lives in the common parent (TodoApp) and changes come in via callbacks
  • Used crypto.randomUUID() for safe keys

Right now our app can only add and delete. In the next post, “Building a Todo App with React #2 Completion Toggle and Stats,” we’ll add a checkbox to each item to mark completion, and add a stats area showing remaining / total counts.

X