096: Forms
Learning objective
Outcomes
You will build accessible controlled inputs, submit and validate in event handlers, reset drafts deliberately, and support both task creation and editing.
I can explain how value/checked, state, and onChange keep browser controls synchronized.
Prerequisites
Complete 095. You should understand callback props, event submission, state snapshots, and why 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 already has internal state. A controlled input makes React state authoritative: JSX passes its current value (or checkbox checked), and onChange synchronously stores the user's next value.
- Controlled input: Input whose value comes from React state, updated onChange. — Source: React DOM: controlled
- Uncontrolled input: Input keeping its own DOM state, read when needed via refs/defaultValue. — Source: React DOM: uncontrolled
- Draft state: State mirroring in-progress form edits before submission. — Source: React: Managing state
- Client validation: Immediate checks on draft values guiding correction before submit. — Source: MDN: Client-side form validation
- Submit event: Form submission intercepted with onSubmit preventDefault pattern. — Source: React DOM: submit handling
- Reset: Restoring controlled inputs by setting state back to initial values. — Source: React DOM: reset behavior
Controlled text values must remain strings, not switch between undefined and strings. Controlled checkboxes use checked and event.target.checked.
Beginner complete example
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.
noValidate is used only because this example demonstrates custom validation and messages. Native HTML validation is often preferable; if using it, remove noValidate and still validate on the server.
Controlled control patterns
<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)} />
Do not use selected on an <option> in controlled React; control the <select>. Do not pass value without onChange unless it is intentionally readOnly. A button inside a form defaults to submit, so use explicit type="button" for clear, cancel, edit, and delete controls.
Intermediate: create and edit continuity
Editing needs a draft separate from the saved task. The parent still owns committed tasks:
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 intentionally copies the initial title. If the task prop changes while the same form remains mounted, decide product behavior. Often rendering <EditTaskForm key={task.id} ... /> makes each selected task a distinct editor and resets all draft state without an Effect. Do not use an Effect merely to copy task.title into draft state.
Validation strategy
Validate at appropriate layers:
- HTML attributes such as
required,minLength, and correct input types provide browser behavior. - Client JavaScript supplies domain-specific feedback and avoids pointless requests.
- Server validation remains mandatory because clients can be bypassed and data can race.
Do not disable submit as the only validation feedback; disabled controls cannot explain why. If you disable it, also provide persistent requirements and visible errors after attempted submit. Trim for validation and submission, but do not transform the controlled value on every keystroke because that can move the caret unexpectedly.
Optional advanced: one object versus several state variables
For two fields, separate state is clear. An object can reduce setter names:
const [draft, setDraft] = useState({ title: '', priority: 'normal' });
setDraft((current) => ({ ...current, title: event.target.value }));
React does not merge state objects. Always retain fields with spread. Avoid a generic change handler until repeated controls really benefit; explicit handlers are easier for beginners and preserve correct types for checkboxes and numbers. Remember that type="number" still reports a string; parse only when your domain needs a number.
Current React form actions offer additional patterns, especially with frameworks and server functions, but this Vite client curriculum needs controlled state and event handling fundamentals first.
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.
Inspect React state and the DOM value together. If they diverge, trace value → onChange → setter. Test Enter submission, mouse, keyboard tab order, invalid submit, successful reset, and cancel. Use unique IDs; repeated forms cannot share a hard-coded id.
Accessibility and performance
Every control needs a visible label, nested or connected with htmlFor/id. Associate help and error text using aria-describedby; set aria-invalid only according to validity. Focus the first invalid field for long forms, and announce newly appearing errors carefully. Group related radios with fieldset and legend.
Controlled inputs render on each edit; this is expected. Keep draft state in the form component so the whole application need not recalculate. If a measured large dependent view remains slow, component extraction or useDeferredValue may help, but do not defer the input's own value or 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.
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 using value/checked and synchronous onChange. Submit, validation, and reset are event logic. Keep drafts separate from committed records, update saved tasks immutably, label every control, and avoid Effects that copy props to 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 one. Use it for a DOM handle, timer ID, or imperative widget; do not use it as hidden reactive state. An uncontrolled input lets the DOM own its draft and is useful for large forms or non-React integrations:
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>;
}
Failure case: changing defaultValue does not reset a mounted input; adding value without onChange makes it read-only; switching between modes produces a warning. Test typing, blank submit, successful reset, and expect(input).toHaveFocus(). Dialogs should focus their first meaningful control on open, return focus to the invoking button on close, support Escape, and contain focus in production. Interview follow-ups: 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. Distinguish 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 user input after an error and restore focus intentionally after success or failure.
2026 depth expansion: controlled versus uncontrolled is an ownership decision
Controlled inputs are excellent when the rendered UI must immediately depend on every keystroke. Uncontrolled inputs are often simpler when the form itself can own the draft and you only need the values on submit.
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 later make the second pattern particularly important. Do not assume that “React form” means “put every field in useState.”
File inputs
<input type="file"> remains uncontrolled. Read selected files from the element/FormData and 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. A server must repeat authorization, 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
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.
Controlled input
function SearchForm() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}
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>
);
}
The browser owns the draft value until you read it.
This can be simpler when per-keystroke React state is unnecessary.
Do not switch control mode accidentally
Problem:
<input value={maybeUndefined} onChange={...} />
If value starts undefined and later becomes a string, React can warn about uncontrolled → controlled transition.
For controlled text inputs, initialize:
const [title, setTitle] = useState('');
For numbers, remember HTML input values are strings:
const quantity = Number(event.target.value);
and validate NaN.
FormData deep dive
HTML forms already know how to serialize named controls.
const formData = new FormData(event.currentTarget);
Fields without name are not submitted.
Checkbox:
<input type="checkbox" name="archived" value="yes" />
If unchecked, it may be absent from FormData rather than returning "false".
Multiple values:
const tags = formData.getAll('tags');
File:
const file = formData.get('attachment');
This connects directly to browser platform knowledge from the HTML module.
Native validation
<input
name="title"
required
minLength={3}
maxLength={80}
/>
Native constraints provide immediate browser validation.
They are not server security.
Use:
input.reportValidity()
or form validity APIs only when custom interaction genuinely needs them.
Do not disable native validation (noValidate) unless your custom validation UX fully replaces it.
Controlled checkbox
const [done, setDone] = useState(false);
<input
type="checkbox"
checked={done}
onChange={(event) => setDone(event.target.checked)}
/>
Use checked, not value, for 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.
File inputs
File inputs cannot be controlled like text inputs.
Use:
<input
type="file"
name="attachment"
accept="image/*,.pdf"
/>
Then:
const file = new FormData(form).get('attachment');
accept is guidance, not trust. The server must validate:
- MIME/type;
- file signature if important;
- size;
- filename/path handling;
- malware policy where relevant.
Validation layers
A robust form has several layers.
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 same client can submit outdated data. The server decides truth.
Server validation response design
A predictable response:
{
"error": {
"code": "VALIDATION_ERROR",
"fields": {
"title": "A task with this title already exists"
}
}
}
lets the UI map the message back to the field.
Do not return a 500 for expected validation failure.
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>
)}
Do not rely on red borders 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>
But also consider:
- cancellation;
- retry;
- offline;
- 409 conflict;
- validation;
- server timeout.
A spinner is not a full error strategy.
Reset behavior
After successful create:
event.currentTarget.reset();
for uncontrolled form.
Controlled form:
setTitle('');
setPriority('normal');
Do not reset at submit start. If the request fails, the user loses their work.
React 19 connection
Later form Actions make this pattern possible:
<form action={saveTaskAction}>
and give React-aware pending and optimistic behavior.
This does not invalidate the browser platform. FormData, names, native controls, validation semantics, and server authority still matter.
Failure clinic
Missing name
<input id="email" />
looks fine but FormData will not contain "email".
Copying server data into controlled state too early
When edit data loads asynchronously, decide whether local fields are:
- 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.
Saving parsed number incorrectly
const quantity = event.target.value;
is a string.
Validate and convert.
Worked example: edit form with intentional draft ownership
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, a parent can render:
<TaskEditor key={task.id} task={task} onSave={...} />
This is often clearer than synchronizing draft state with an Effect.
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.
