EduFlow
React Fundamentals
0%

Section 3 · State & Events

Forms & Controlled Inputs

24 min4 min readCode playground

Lesson at a glance

Build the Watchlist’s add-movie form with controlled inputs, handle submission and validation, and know when React 19 form actions and useActionState are the better fit.

  1. 1A controlled input
  2. 2Submitting
  3. 3Other kinds of input
  4. 4React 19: form actions

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Every useful app takes input. For the Watchlist that means a form: type a title, optionally a year, press Enter or click Add, and the movie appears in the list. Forms are where state and events meet, and where most beginner bugs live: inputs that won’t accept typing, pages that reload on submit, numbers that concatenate like strings. Each one has a clear cause.

A controlled input

In plain HTML the input owns its value; you read it when you need it. In React the usual approach flips that: state owns the value, and the input displays it.

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

export default function AddMovieForm() {
  const [title, setTitle] = useState('');

  return (
    <form>
      <label htmlFor="title">Title</label>
      <input
        id="title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <p>{title.length}/80 characters</p>
    </form>
  );
}

This is a controlled input. Every keystroke fires onChange, the handler stores the new text in state, React re-renders, and the input shows the state. It sounds roundabout, but now the current text is an ordinary variable. You can show a character count, validate as the user types, disable a button, or clear the field by setting state to ''.

Note onChange in React fires on every keystroke, like the DOM’s input event, not only when the field loses focus.

Submitting

Handle submission on the form, not on the button’s click:

src/components/AddMovieForm.jsx
export default function AddMovieForm({ onAdd }) {
  const [title, setTitle] = useState('');
  const trimmed = title.trim();

  function handleSubmit(e) {
    e.preventDefault();
    if (!trimmed) return;
    onAdd(trimmed);
    setTitle('');
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="title">Title</label>
      <input id="title" value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="submit" disabled={!trimmed}>Add</button>
    </form>
  );
}

onSubmit on the form means pressing Enter in the input works, the button works, and assistive technology understands the form, all without extra code. e.preventDefault() stops the browser’s default, which is to send the form to a URL and reload the page. The component reports the new title through onAdd and clears the field by resetting state.

Validation comes from state too. trimmed is derived on every render, so the button is disabled exactly while the title is blank, including a title of only spaces. The if (!trimmed) return; inside the handler is a second guard in case the form is submitted another way.

Other kinds of input

Every input type follows the same pattern with small differences:

JSX
// Numbers arrive as strings. Keep the string in state, convert on use.
<input type="number" value={year} onChange={(e) => setYear(e.target.value)} />
const yearNumber = year === '' ? null : Number(year);

// Checkboxes use checked, not value.
<input type="checkbox" checked={watched} onChange={(e) => setWatched(e.target.checked)} />

// A select is controlled through value on the <select>.
<select value={genre} onChange={(e) => setGenre(e.target.value)}>
  <option value="drama">Drama</option>
  <option value="sci-fi">Sci-fi</option>
</select>

The number case catches everyone once: e.target.value is always a string, even for type="number", so year + 1 gives “20161”. Keeping the raw string in state also means an empty field stays empty instead of turning into 0.

React 19: form actions

Controlled inputs are best when you react to every keystroke. Many forms only matter at the moment of submission, and React 19 added first-class support for those: pass a function to the form’s action prop.

JSX
function QuickAdd({ onAdd }) {
  function addAction(formData) {
    onAdd(formData.get('title').trim());
  }

  return (
    <form action={addAction}>
      <input name="title" required />
      <button type="submit">Add</button>
    </form>
  );
}

React prevents the page reload for you, calls your function with a FormData object built from the fields’ name attributes, and resets the form’s uncontrolled fields when the action finishes. The fields aren’t controlled at all.

When you need a result from the submission, such as an error message, or a pending state while async work runs, add useActionState:

JSX
import { useActionState } from 'react';

function QuickAdd({ onAdd }) {
  const [error, formAction, isPending] = useActionState(async (prevError, formData) => {
    const title = formData.get('title').trim();
    if (!title) return 'Title is required';
    await onAdd(title); // could be a request to a server
    return null;
  }, null);

  return (
    <form action={formAction}>
      <input name="title" aria-invalid={error ? true : undefined} />
      <button type="submit" disabled={isPending}>{isPending ? 'Adding…' : 'Add'}</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

The action receives the previous state and the form data and returns the next state. isPending is true while it runs. A child component inside the form can also read the pending status with useFormStatus from react-dom.

Which should you use? A sensible default: controlled inputs when the UI reacts as the user types (counts, live validation, dependent fields); actions when the work happens on submit, especially if it’s async. The Watchlist’s add form disables its button while the title is blank, so it uses a controlled input, and that’s what you’ll build in the exercise.

Next, you’ll connect this form to the list. The form and the list are siblings, so the movies have to live in their common parent. That’s lifting state up.

Key takeaways

  • A controlled input gets its value from state and reports every keystroke through onChange, so state is the single source of truth.
  • Handle onSubmit on the <form> and call e.preventDefault(); you get Enter-to-submit and accessible behaviour for free.
  • Validate from state while rendering, for example by disabling the button when the trimmed title is empty.
  • Input values are always strings; convert numbers yourself and use checked for checkboxes.
  • React 19 form actions pass FormData to a function and reset the form; useActionState adds a result and a pending flag.

Further reading

Check your understanding

4 questions

Question 1 of 4

You render <input value={title} /> with no onChange. What happens when the user types?

Question 2 of 4

The year field is <input type="number" value={year} onChange={(e) => setYear(e.target.value)} />. The user types 2016. What type is year, and what does year + 1 give?

Question 3 of 4

Your form adds a movie and should clear the title afterwards. Which handleSubmit is right for a controlled input?

Question 4 of 4

When is a React 19 form action with useActionState a better fit than controlled inputs?

Practice

Code playground

The add-movie form

Build AddMovieForm so it adds movies to the list. Keep the ids title and year on the inputs.

  • Make both inputs controlled (value + onChange).
  • On submit, call onAdd(title, year) with the trimmed title and the year as a number, or null when the year field is empty. Don’t let the page reload.
  • After adding, clear both fields.
  • The Add button is disabled while the trimmed title is empty.

App already stores the movies and renders them as “Title (Year)”, or just “Title” when there’s no year.

import { useState } from 'react';

function AddMovieForm({ onAdd }) {
  // Make both inputs controlled, handle submit, and disable Add while the title is blank.
  return (
    <form>
      <label htmlFor="title">Title</label>
      <input id="title" />
      <label htmlFor="year">Year</label>
      <input id="year" type="number" />
      <button type="submit">Add</button>
    </form>
  );
}

let nextId = 1; // in a real app: crypto.randomUUID()

export default function App() {
  const [movies, setMovies] = useState([]);

  function addMovie(title, year) {
    setMovies([...movies, { id: nextId++, title, year }]);
  }

  return (
    <main>
      <AddMovieForm onAdd={addMovie} />
      <ul>
        {movies.map((m) => (
          <li key={m.id}>{m.year ? `${m.title} (${m.year})` : m.title}</li>
        ))}
      </ul>
    </main>
  );
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Add is disabled when the title is empty or only spaces
  • Add is enabled once a title is typed
  • Submitting adds “Dune (2021)” to the list
  • Both fields are cleared after adding
  • A padded title is trimmed, and an empty year shows the title alone