Module: React and Ecosystem
React and Ecosystem·096·10 MIN READ

096: Forms

TOPICS COVERED: 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

  1. Why does submit logic belong in an event handler rather than an Effect?
  2. Which component should own a temporary add-form draft?
  3. 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 text values must remain strings, not switch between undefined and strings. Controlled checkboxes use checked and event.target.checked.

Beginner complete example

jsx
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

jsx
<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:

jsx
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:

jsx
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:

  1. HTML attributes such as required, minLength, and correct input types provide browser behavior.
  2. Client JavaScript supplies domain-specific feedback and avoids pointless requests.
  3. 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:

jsx
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: value exists but onChange does not update it synchronously.
  • Controlled/uncontrolled warning: initialize text with '', checkbox with false.
  • Checkbox reads value instead of checked.
  • 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:

jsx
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

  1. What problem does this concept solve?
  2. What is one common mistake?
  3. 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

  1. What causes a controlled input to become impossible to type into?
  2. Why is edit draft state different from duplicated committed state?
  3. 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:

jsx
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.

jsx
function SearchBox() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

versus:

jsx
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:

text
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

jsx
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

jsx
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:

jsx
<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:

jsx
const [title, setTitle] = useState('');

For numbers, remember HTML input values are strings:

jsx
const quantity = Number(event.target.value);

and validate NaN.

FormData deep dive

HTML forms already know how to serialize named controls.

jsx
const formData = new FormData(event.currentTarget);

Fields without name are not submitted.

Checkbox:

jsx
<input type="checkbox" name="archived" value="yes" />

If unchecked, it may be absent from FormData rather than returning "false".

Multiple values:

jsx
const tags = formData.getAll('tags');

File:

jsx
const file = formData.get('attachment');

This connects directly to browser platform knowledge from the HTML module.

Native validation

jsx
<input
  name="title"
  required
  minLength={3}
  maxLength={80}
/>

Native constraints provide immediate browser validation.

They are not server security.

Use:

jsx
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

jsx
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

jsx
<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:

jsx
<select multiple ...>

requires handling a collection of selected options.

File inputs

File inputs cannot be controlled like text inputs.

Use:

jsx
<input
  type="file"
  name="attachment"
  accept="image/*,.pdf"
/>

Then:

jsx
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:

text
required
format
minimum length

Schema/client form validation

Complex cross-field rules:

text
endDate >= startDate
password confirmation
conditional fields

Server validation

Authoritative rules:

text
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:

json
{
  "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.

jsx
<label htmlFor="task-title">Title</label>
<input id="task-title" name="title" />

Error:

jsx
<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.

jsx
<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:

jsx
event.currentTarget.reset();

for uncontrolled form.

Controlled form:

jsx
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:

jsx
<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

jsx
<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

jsx
const quantity = event.target.value;

is a string.

Validate and convert.

Worked example: edit form with intentional draft ownership

jsx
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:

jsx
<TaskEditor key={task.id} task={task} onSave={...} />

This is often clearer than synchronizing draft state with an Effect.

Exercises

  1. Build controlled and uncontrolled versions of the same form.
  2. Serialize checkboxes, multi-select, and files with FormData.
  3. Add native and server validation layers.
  4. Preserve values on simulated 422.
  5. Add accessible field error relationships.
  6. Reset only after confirmed success.
  7. 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.