096: Forms
Learning objective
Outcomes
You will build accessible controlled inputs, handle submission and validation in event handlers, reset drafts deliberately, and support both creating and editing tasks.
You should be able to explain how value/checked, state, and onChange keep browser controls synchronized.
Prerequisites
Complete 095 first. You should already understand callback props, event submission, state snapshots, and the reason a draft may intentionally differ from committed task data.
Retrieval practice
- Why does submit logic belong in an event handler rather than an Effect?
- Which component should own a temporary add-form draft?
- Why should visible filtered tasks not be stored in state?
Content to cover
controlled inputs; form state; submit; validation; reset.
Terms and mental model
A browser input has state of its own. The useful distinction is ownership: with a controlled input, React state is authoritative. JSX supplies the current value (or, for a checkbox, checked), and onChange records the user's next value. The DOM displays that state; it is not the long-term source of truth.
- Controlled input: An input whose value comes from React state and is updated through onChange. — Source: React DOM: controlled
- Uncontrolled input: An input that keeps its own DOM state and is read when needed through refs or defaultValue. — Source: React DOM: uncontrolled
- Draft state: State that mirrors form edits while they are in progress, before submission commits them. — Source: React: Managing state
- Client validation: Immediate checks against draft values that help the user correct them before submission. — Source: MDN: Client-side form validation
- Submit event: The form-submission event handled with the
onSubmit/preventDefaultpattern. — Source: React DOM: submit handling - Reset: Returning controlled inputs to their initial values by setting their state back explicitly. — Source: React DOM: reset behavior
Controlled text values must stay strings. Do not let one render provide undefined and a later render provide a string. For a controlled checkbox, use checked and read the next boolean from event.target.checked.
Beginner complete example
This small form keeps the draft local to the form, validates at submission, and tells the parent when a valid task is ready to commit:
import { useState } from 'react';
export default function TaskForm({ onAddTask }) {
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('normal');
const [error, setError] = useState('');
function handleSubmit(event) {
event.preventDefault();
const cleanTitle = title.trim();
if (cleanTitle.length < 3) {
setError('Enter at least 3 characters.');
return;
}
onAddTask({
id: crypto.randomUUID(),
title: cleanTitle,
priority,
completed: false,
});
setTitle('');
setPriority('normal');
setError('');
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="task-title">Task title</label>
<input
id="task-title"
name="title"
value={title}
onChange={(event) => {
setTitle(event.target.value);
if (error) setError('');
}}
aria-describedby={error ? 'title-hint title-error' : 'title-hint'}
aria-invalid={Boolean(error)}
required
minLength={3}
/>
<p id="title-hint">Use 3 or more characters.</p>
{error && <p id="title-error" role="alert">{error}</p>}
</div>
<label htmlFor="task-priority">Priority</label>
<select
id="task-priority"
name="priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
</select>
<button type="submit">Add task</button>
<button
type="button"
onClick={() => {
setTitle('');
setPriority('normal');
setError('');
}}
>
Clear form
</button>
</form>
);
}
For a standalone preview, pass onAddTask={(task) => console.log(task)} from App. In the continuing app, the parent appends the object immutably.
The noValidate attribute is present only so this example can demonstrate its own validation and messages. Native HTML validation is often the better default. If you use native validation, remove noValidate, and still validate again on the server.
Controlled control patterns
These are the basic controlled forms for the common control types:
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
<select value={priority} onChange={(e) => setPriority(e.target.value)} />
<input type="checkbox" checked={completed} onChange={(e) => setCompleted(e.target.checked)} />
In controlled React, do not put selected on an <option>; control the <select> itself. A value without an onChange makes the field effectively read-only unless that is intentional and you pass readOnly. Also remember that a button inside a form defaults to submitting the form. Give clear, cancel, edit, and delete controls an explicit type="button".
Intermediate: create and edit continuity
An editor needs a draft that is separate from the saved task. The parent continues to own the committed task collection, while the editor owns the edits that might still be cancelled:
function EditTaskForm({ task, onSave, onCancel }) {
const [title, setTitle] = useState(task.title);
const cleanTitle = title.trim();
function handleSubmit(event) {
event.preventDefault();
if (cleanTitle.length < 3) return;
onSave({ ...task, title: cleanTitle });
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor={`edit-${task.id}`}>Edit task</label>
<input
id={`edit-${task.id}`}
value={title}
onChange={(event) => setTitle(event.target.value)}
required
minLength={3}
/>
<button type="submit" disabled={cleanTitle.length < 3}>Save</button>
<button type="button" onClick={onCancel}>Cancel</button>
</form>
);
}
Parent save handler:
function handleSaveTask(changedTask) {
setTasks((current) => current.map((task) =>
task.id === changedTask.id ? changedTask : task,
));
setEditingId(null);
}
The draft copies the initial title on purpose. If the task prop changes while this same form remains mounted, decide what the product should do. In many cases, rendering <EditTaskForm key={task.id} ... /> makes each selected task a distinct editor and resets all draft state without an Effect. Do not add an Effect merely to copy task.title into draft state; that can overwrite edits and create stale intermediate renders.
Validation strategy
Validation works best as layers with different responsibilities:
- HTML attributes such as
required,minLength, and correct input types provide immediate browser behavior. - Client JavaScript adds domain-specific feedback and avoids requests that are obviously pointless.
- Server validation is still mandatory because clients can be bypassed and data can become stale between checking and saving.
Do not make a disabled submit button your only validation message. A disabled control cannot tell the user what needs fixing. If you disable it, also show persistent requirements and visible errors after an attempted submit. Trim for validation and for the value you submit, but do not rewrite the controlled value on every keystroke: changing it while the user types can move the caret unexpectedly.
Optional advanced: one object versus several state variables
For two fields, separate state variables are easy to follow. An object can be useful when a draft has more fields and one setter name is preferable:
const [draft, setDraft] = useState({ title: '', priority: 'normal' });
setDraft((current) => ({ ...current, title: event.target.value }));
React does not merge state objects for you. Retain the other fields with spread syntax. Also, do not reach for a generic change handler until repeated controls genuinely benefit from one; explicit handlers are easier for beginners and help preserve the correct types for checkboxes and numbers. Even type="number" reports a string from the DOM, so parse it only when the domain needs a number.
Current React form actions provide additional patterns, especially with frameworks and server functions. This Vite client curriculum starts with controlled state and event handling because those fundamentals make the later patterns easier to reason about.
Mistakes and debugging
- Input will not type:
valueexists butonChangedoes not update it synchronously. - Controlled/uncontrolled warning: initialize text with
'', checkbox withfalse. - Checkbox reads
valueinstead ofchecked. - Clear button accidentally submits because
type="button"is missing. - Validation occurs only after writing invalid committed data.
- Form puts submit work in an Effect.
- Effect copies props into edit state, causing stale flashes and extra renders.
- Mutating the saved task while editing destroys cancel behavior.
- Placeholder replaces a label, leaving no persistent accessible name.
When debugging, inspect React state and the DOM value together. If they differ, trace the chain value → onChange → setter. Exercise both Enter submission and mouse submission, keyboard tab order, invalid submit, successful reset, and cancel. Use unique IDs; multiple forms must not share a hard-coded id.
Accessibility and performance
Every control needs a visible label, either nested or connected with htmlFor/id. Associate help and error text with aria-describedby, and set aria-invalid only when the field is actually invalid. For a long form, focus the first invalid field where that improves recovery, and announce newly appearing errors carefully. Group related radio buttons with fieldset and legend.
Controlled inputs render on each edit, and that is expected. Keep draft state in the form component so the entire application does not need to recalculate for every keystroke. If a measured, large dependent view remains slow, component extraction or useDeferredValue may help. Do not defer the input's own value, and do not add useMemo/useCallback by default.
Practice
Build a task creation/edit form.
Tiered exercises
Core: Controlled title and priority, submit validation, successful reset.
Stretch: Add edit mode with save/cancel and immutable parent replacement.
Challenge: Render editors for selectable tasks, use a stable task key to reset the draft, and preserve focus/error semantics.
Parent integration:
function App() {
const [tasks, setTasks] = useState([]);
const [editingId, setEditingId] = useState(null);
const editingTask = tasks.find((task) => task.id === editingId) ?? null;
function addTask(task) {
setTasks((current) => [...current, task]);
}
function saveTask(changed) {
setTasks((current) => current.map((task) =>
task.id === changed.id ? changed : task,
));
setEditingId(null);
}
return (
<main>
<h1>Task Manager</h1>
<TaskForm onAddTask={addTask} />
{editingTask && (
<EditTaskForm
key={editingTask.id}
task={editingTask}
onSave={saveTask}
onCancel={() => setEditingId(null)}
/>
)}
<ul>{tasks.map((task) => (
<li key={task.id}>
{task.title}{' '}
<button type="button" onClick={() => setEditingId(task.id)}>
Edit {task.title}
</button>
</li>
))}</ul>
</main>
);
}
Use the complete TaskForm and EditTaskForm above. The key resets the editor when a different task is selected; no synchronization Effect is needed. The immutable map update also leaves the original task object untouched until the user explicitly saves.
Exit questions
- What problem does this concept solve?
- What is one common mistake?
- Can you explain the code without reading it line by line?
Recap
Controlled forms synchronize React state with browser controls through value/checked and a synchronous onChange. Submission, validation, and reset are event logic. Keep drafts separate from committed records, replace saved tasks immutably, label every control, and avoid Effects that copy props into state.
Official references
Interview questions
- What causes a controlled input to become impossible to type into?
- Why is edit draft state different from duplicated committed state?
- How do you make validation errors usable by keyboard and assistive technology?
Strong answer: Keep controlled values defined, update them synchronously from onChange, validate on submit and at the server, associate errors with labels/aria-describedby, and reset an editor with a deliberate key or unmount when identity changes.
Refs, uncontrolled inputs, and focus
useRef stores a mutable value across renders without scheduling a render. It is appropriate for a DOM handle, timer ID, or imperative widget; it is not a substitute for reactive state. An uncontrolled input lets the DOM own its draft, which can be useful for large forms or integrations that are not managed by React:
import { useRef } from 'react';
export function UncontrolledTaskForm({ onAdd }) {
const inputRef = useRef(null);
function handleSubmit(event) {
event.preventDefault();
const title = inputRef.current.value.trim();
if (!title) return;
onAdd(title); event.currentTarget.reset(); inputRef.current.focus();
}
return <form onSubmit={handleSubmit}><label htmlFor="uncontrolled-title">Task</label><input id="uncontrolled-title" ref={inputRef} defaultValue="" /><button type="submit">Add</button></form>;
}
There are several easy failure modes here: changing defaultValue does not reset an input that is already mounted; adding value without onChange makes it read-only; and switching between controlled and uncontrolled modes produces a warning. Test typing, blank submission, successful reset, and expect(input).toHaveFocus(). A dialog should focus its first meaningful control on open, return focus to the invoking button on close, support Escape, and contain focus in production. Useful interview follow-ups are: when is uncontrolled preferable, and what must unmounting a dialog do for focus?
Controlled forms and asynchronous validation
A controlled input has one source of truth in React state, but a real submission still has several separate pieces of state. Distinguish the draft value, field error, submission status, server error, and successful result. Treat server validation as authoritative.
Test keyboard-only completion, invalid values, slow submission, double activation, server field errors, network failure, reset, and unmount during submission. Preserve the user's input after an error, and restore focus intentionally after success or failure.
2026 depth expansion: controlled versus uncontrolled is an ownership decision
The decision is about who owns the draft, not about whether one approach is more “React-like.” Controlled inputs are a strong fit when rendered UI must respond immediately to every keystroke. Uncontrolled inputs are often simpler when the form can own the draft and the application only needs the values at submit time.
function SearchBox() {
const [query, setQuery] = useState('');
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
versus:
function SimpleForm() {
function submit(formData) {
const title = String(formData.get('title') ?? '').trim();
// validate and submit
}
return (
<form action={submit}>
<input name="title" />
<button>Save</button>
</form>
);
}
React 19 Actions make the second pattern particularly important. Do not assume that a “React form” means putting every field in useState.
File inputs
<input type="file"> remains uncontrolled. Read selected files from the element or from FormData, then send them with multipart/form-data; do not try to control the file input's value.
Server validation is authoritative
Client validation is a usability layer, not the final authority. The server must repeat authorization checks, business rules, and validation. Later lessons show how to merge server field errors into React Hook Form and React Actions.
Deep dive: form architecture begins with ownership
Before choosing an API, decide where the draft belongs. A form can be:
browser-owned draft
React-owned draft
form-library-owned draft
server-action-oriented
There is no rule that every React form must control every input. The right choice depends on whether other UI needs to react to each edit, whether the form is integrated with a library or server action, and when the value needs to be read.
Controlled input
function SearchForm() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}
This is useful when the UI must respond to each keystroke:
- live filtering;
- character counter;
- dependent controls;
- immediate formatted preview.
Uncontrolled input
function SimpleTaskForm() {
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = String(formData.get('title') ?? '').trim();
console.log(title);
}
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<button>Save</button>
</form>
);
}
Here the browser owns the draft until submission reads it. That can be simpler when React does not need to react to every keystroke.
Do not switch control mode accidentally
Problem:
<input value={maybeUndefined} onChange={...} />
If value starts as undefined and later becomes a string, React can warn about an uncontrolled → controlled transition. The warning is telling you that ownership changed during the component's lifetime.
For controlled text inputs, initialize with a string:
const [title, setTitle] = useState('');
For numbers, remember that HTML input values are strings:
const quantity = Number(event.target.value);
Validate the result for NaN before using it as a domain value.
FormData deep dive
HTML forms already know how to serialize named controls. In an event handler, create a FormData snapshot like this:
const formData = new FormData(event.currentTarget);
Fields without a name are not included in that submission data.
Checkbox:
<input type="checkbox" name="archived" value="yes" />
If the checkbox is unchecked, it may be absent from FormData rather than producing a string such as "false".
Multiple values:
const tags = formData.getAll('tags');
File:
const file = formData.get('attachment');
This behavior connects directly to the browser-platform knowledge from the HTML module. The browser serializes named successful controls; it does not infer every piece of component state.
Native validation
<input
name="title"
required
minLength={3}
maxLength={80}
/>
Native constraints give the browser enough information to provide immediate validation. They are not server security and cannot replace server-side checks.
Use:
input.reportValidity()
or the form validity APIs only when custom interaction genuinely needs them. Do not disable native validation with noValidate unless your custom validation experience fully replaces the browser behavior.
Controlled checkbox
const [done, setDone] = useState(false);
<input
type="checkbox"
checked={done}
onChange={(event) => setDone(event.target.checked)}
/>
Use checked, not value, when the state you are controlling is the boolean checked state.
Select
<select
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
</select>
Multiple select:
<select multiple ...>
requires handling a collection of selected options rather than one scalar value.
File inputs
File inputs cannot be controlled in the same way as text inputs. The selected file is owned by the browser for security reasons.
Use:
<input
type="file"
name="attachment"
accept="image/*,.pdf"
/>
Then:
const file = new FormData(form).get('attachment');
accept guides the chooser; it is not a trust boundary. The server must validate:
- MIME/type;
- file signature if important;
- size;
- filename/path handling;
- malware policy where relevant.
Validation layers
A robust form uses several validation layers because each one answers a different question.
Browser/client guidance
Fast feedback:
required
format
minimum length
Schema/client form validation
Complex cross-field rules:
endDate >= startDate
password confirmation
conditional fields
Server validation
Authoritative rules:
unique username
permission
inventory available
tenant ownership
business policy
The client may be submitting outdated data, and a caller can bypass its checks entirely. The server decides what is true.
Server validation response design
A predictable response lets the UI associate a server message with the field that needs attention:
{
"error": {
"code": "VALIDATION_ERROR",
"fields": {
"title": "A task with this title already exists"
}
}
}
Do not return a 500 for an expected validation failure. A validation error is an expected client-correction path, not an unexpected server crash.
Form accessibility
Every form control needs an accessible name:
<label htmlFor="task-title">Title</label>
<input id="task-title" name="title" />
Error:
<input
id="task-title"
aria-invalid={Boolean(error)}
aria-describedby={error ? 'task-title-error' : undefined}
/>
{error && (
<p id="task-title-error" role="alert">
{error}
</p>
)}
The programmatic relationship means assistive technology can connect the field to its current error. Do not rely on a red border alone.
Focus after error
After submission:
- preserve values;
- show a summary if the form is large;
- focus first invalid field where appropriate;
- avoid moving focus on every keystroke.
Later React Hook Form provides helpers for this.
Pending forms
Pending state should prevent harmful duplicate actions without trapping users:
<button disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
That state is only one part of the interaction. Also consider:
- cancellation;
- retry;
- offline;
- 409 conflict;
- validation;
- server timeout.
A spinner does not provide a complete error strategy.
Reset behavior
After successful create, an uncontrolled form can reset itself:
event.currentTarget.reset();
for uncontrolled form.
For a controlled form, reset each piece of draft state:
setTitle('');
setPriority('normal');
Do not reset at the beginning of submission. If the request fails, resetting then would discard the user's work before you know whether the operation succeeded.
React 19 connection
Later form Actions make this pattern possible:
<form action={saveTaskAction}>
and give React-aware pending and optimistic behavior.
That does not make the browser platform irrelevant. FormData, names, native controls, validation semantics, and server authority still matter.
Failure clinic
Missing name
<input id="email" />
This looks fine in the page, but FormData will not contain "email" because the control has no name.
Copying server data into controlled state too early
When edit data loads asynchronously, decide what local fields mean. They might be:
- a fresh draft initialized once;
- continuously synchronized;
- reset on record identity change.
Do not blindly run an Effect that overwrites user edits whenever query data refetches. A refetch is not automatically a request to discard a draft.
Saving parsed number incorrectly
const quantity = event.target.value;
is a string. Validate and convert it when the domain requires a number.
Worked example: edit form with intentional draft ownership
This editor deliberately owns its draft. It trims only when preparing the value to save, rather than changing what the user sees on every keystroke:
function TaskEditor({ task, onSave }) {
const [draft, setDraft] = useState(() => ({
title: task.title,
priority: task.priority,
}));
function handleSubmit(event) {
event.preventDefault();
const title = draft.title.trim();
if (title.length < 3) {
return;
}
onSave({
id: task.id,
...draft,
title,
});
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="edit-title">Title</label>
<input
id="edit-title"
value={draft.title}
onChange={(event) =>
setDraft((current) => ({
...current,
title: event.target.value,
}))
}
/>
<button>Save</button>
</form>
);
}
If task.id changes and a fresh draft is required, the parent can render:
<TaskEditor key={task.id} task={task} onSave={...} />
This is often clearer than synchronizing draft state with an Effect. The key expresses that a different record is a different editor, so React mounts fresh initial state for it.
Exercises
- Build controlled and uncontrolled versions of the same form.
- Serialize checkboxes, multi-select, and files with FormData.
- Add native and server validation layers.
- Preserve values on simulated 422.
- Add accessible field error relationships.
- Reset only after confirmed success.
- Explain when React Hook Form becomes worthwhile.
Mastery check
Explain:
- controlled versus uncontrolled ownership;
- FormData behavior;
- native validation versus server validation;
- why file inputs differ;
- why error accessibility needs programmatic relationships;
- why form values should survive server failure.
