FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·096·13 MIN READ

096: Forms

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

  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 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/preventDefault pattern. — 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:

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.

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:

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)} />

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:

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

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

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

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:

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. The immutable map update also leaves the original task object untouched until the user explicitly saves.

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

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

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>;
}

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.

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

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

jsx
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

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>
  );
}

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:

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

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

For numbers, remember that HTML input values are strings:

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

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

Fields without a name are not included in that submission data.

Checkbox:

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

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

File:

jsx
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

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

jsx
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

jsx
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

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

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

Then:

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

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

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

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>
)}

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:

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

jsx
event.currentTarget.reset();

for uncontrolled form.

For a controlled form, reset each piece of draft state:

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

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

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

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

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, the parent can render:

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

  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.
Reader page: /react/lesson/096/forms