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.
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.
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:
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:
// 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.
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:
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
valuefrom state and reports every keystroke throughonChange, so state is the single source of truth. - Handle
onSubmiton the<form>and calle.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
checkedfor checkboxes. - React 19 form actions pass
FormDatato a function and reset the form;useActionStateadds a result and a pending flag.