Prerequisites

Before you start, it helps to know:

  • Basic JavaScript (variables, functions, arrays)
  • What JSX looks like
  • What useState does in React (we'll explain it anyway, but a little familiarity helps)

You don't need any extra libraries for this — everything here uses plain React.

Architecture

Here's the general flow we're building:

  • The user types something into an input box.
  • React "listens" to every keystroke and stores it in state.
  • When the user submits the form (by clicking a button or pressing Enter), React takes that stored text and adds it to a list of todos.
  • The list of todos is stored in its own state, and React re-renders the page to show the updated list.
  • The input box clears itself, ready for the next todo.

How it works

React doesn't let the browser manage form data on its own the way plain HTML does. Instead, React keeps the input's value in state, and updates that state every time the user types. This pattern is called a controlled component — the input's value is always "controlled" by React, not by the DOM.

This might feel like extra work at first, but it gives you full control: you can validate input as the user types, disable the submit button until the form is valid, or format the text before saving it.

Notes

  • We'll use function components and hooks (useState), which is the standard way to write React today.
  • We'll keep styling minimal so the focus stays on the logic.
  • This same pattern (controlled input → state → list) works for almost any form, not just todo apps.

Setting up the component

Start with a basic component that will hold both the input box and the todo list.

import { useState } from "react";

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

  return (
    <div>
      <h1>My Todo List</h1>
      {/* form will go here */}
    </div>
  );
}

export default TodoApp;

Here we've created two pieces of state:

  • inputValue — holds whatever the user is currently typing.
  • todos — holds the full list of todos we've added so far.

Building the controlled input

Now let's add the actual input box and connect it to inputValue.

<input
  type="text"
  value={inputValue}
  onChange={(e) => setInputValue(e.target.value)}
  placeholder="What do you need to do?"
/>

Every time the user types a letter, onChange fires. It reads the current text from the input (e.target.value) and saves it into state using setInputValue. React then re-renders the input with that saved value — which is why it's called a "controlled" component. The input never manages its own value; React does.

Why value={inputValue} matters

It's tempting to skip the value prop and just use onChange to track typing. But without value={inputValue}, your input becomes uncontrolled — the browser manages it instead of React.

Setting value={inputValue} matters because:

  • It keeps React as the single source of truth for what's in the box.
  • It lets you reset the input (for example, clearing it after submit) just by updating state — no need to touch the DOM directly.
  • It makes validation and formatting easy, since you can inspect or change the value before it's ever shown to the user.

Creating the submit handler

Next, we need a way to take the current inputValue and turn it into a new todo. We'll wrap the input in a <form> and handle the submit event.

function handleSubmit(e) {
  e.preventDefault(); // stop the page from reloading

  if (inputValue.trim() === "") return; // ignore empty todos

  const newTodo = {
    id: Date.now(),
    text: inputValue,
    completed: false,
  };

  setTodos([...todos, newTodo]);
  setInputValue(""); // clear the input box
}

<form onSubmit={handleSubmit}>
  <input
    type="text"
    value={inputValue}
    onChange={(e) => setInputValue(e.target.value)}
    placeholder="What do you need to do?"
  />
  <button type="submit">Add Todo</button>
</form>

How state updates behave

A common question: why do we write setTodos([...todos, newTodo]) instead of just pushing to the array?

React state should never be changed directly (no .push(), no .splice()). Instead, you create a new array or object and pass that to the setter function. This is what tells React "something changed, please re-render."

The [...todos, newTodo] part uses the spread operator to copy every existing todo into a brand-new array, then adds newTodo at the end. The old todos array is left untouched.

Understanding handleChange

You'll notice we wrote the input's onChange inline as an arrow function. As your form grows (say, with multiple fields), it's cleaner to pull this into its own named function:

function handleChange(e) {
  setInputValue(e.target.value);
}

<input
  type="text"
  value={inputValue}
  onChange={handleChange}
  placeholder="What do you need to do?"
/>

This does exactly the same thing, but it's easier to read and easier to extend later — for example, if you want to trim whitespace or block certain characters before saving the value.

Understanding handleSubmit

Let's break down handleSubmit line by line, since it's doing a few important things at once:

  • e.preventDefault() stops the browser's default behavior of reloading the page when a form is submitted. Without this line, your app would refresh and lose all its state.
  • The if (inputValue.trim() === "") return; line is a simple guard clause — it stops empty or whitespace-only todos from being added.
  • We build a newTodo object with a unique id (using Date.now() as a quick, simple ID), the text the user typed, and a completed flag set to false.
  • setTodos([...todos, newTodo]) adds it to the list.
  • setInputValue("") clears the input box so the user can type the next todo right away.

Managing the todo list lifecycle

Adding todos is only half the story. A real todo app also needs to display, complete, and delete them.

Displaying the list:

<ul>
  {todos.map((todo) => (
    <li key={todo.id}>
      <span
        style={{
          textDecoration: todo.completed ? "line-through" : "none",
        }}
        onClick={() => toggleComplete(todo.id)}
      >
        {todo.text}
      </span>
      <button onClick={() => deleteTodo(todo.id)}>Delete</button>
    </li>
  ))}
</ul>

Toggling and deleting:

function toggleComplete(id) {
  setTodos(
    todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  );
}

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

Notice the pattern repeats: never mutate the existing array directly. toggleComplete uses .map() to build a new array where only the matching todo is changed. deleteTodo uses .filter() to build a new array without the removed todo.

The key={todo.id} prop on each <li> is also important — React uses it to keep track of which list item is which, so updates are fast and correct even as items are added, removed, or reordered.

Production considerations

Before shipping a form like this to real users, think about:

  • Validation — check for empty input, maximum length, or duplicate todos before adding them.
  • Accessibility — use a <label> for your input, and make sure the delete/complete buttons are reachable by keyboard.
  • Persistence — right now, todos disappear on page refresh. Consider saving them to localStorage or a backend database.
  • Unique IDs — Date.now() works for a demo, but in a real app with fast-clicking users, consider a proper unique ID generator to avoid collisions.
  • Large lists — if your todo list grows very large, consider pagination or virtualization for performance.

Implementation checklist

  • Set up inputValue and todos state with useState
  • Build a controlled <input> tied to inputValue
  • Wrap the input in a <form> with onSubmit={handleSubmit}
  • Prevent default form behavior and guard against empty input
  • Add new todos immutably using the spread operator
  • Render the todo list with .map() and a unique key
  • Add toggle-complete and delete functionality
  • Add validation, accessibility, and persistence before going to production

When this pattern is the right choice

This controlled-input pattern is the right choice any time you need to:

  • Validate or transform user input as they type
  • Reset a form after submission
  • Keep multiple form fields in sync with each other
  • Build dynamic lists (todos, comments, cart items) driven by user input

If you're building a very large form with many fields, you might eventually reach for a form library like React Hook Form or Formik — but understanding this basic pattern first will make those libraries much easier to learn, since they're built on the same core ideas.

Conclusion

Handling forms in React comes down to one core idea: let state be the single source of truth. The input's value lives in state, changes flow through onChange, and submissions flow through onSubmit — never by reading or writing the DOM directly.

Once you're comfortable with this pattern, you can reuse it for almost any form-driven feature: todo lists, search boxes, comment forms, or multi-step wizards. It's a small pattern, but it's one of the most important ones in all of React.