Building Forms in React

Learn to build controlled and uncontrolled form inputs across all common field types, implement validation and error/dirty/touched state, handle async submission, and structure accessible, keyboard-friendly forms.

Forms are where a huge share of real user interaction happens — signups, checkouts, settings pages, search filters — and React gives you two complementary approaches for managing form input: controlled components, where React state is the single source of truth for each field's value, and uncontrolled components, where the DOM itself holds the current value and you read it out via a ref when needed.

Controlled vs uncontrolled inputs

A controlled input has its value prop driven entirely by React state, and every keystroke goes through an onChange handler that updates that state, which then re-renders the input with the new value. This gives you a single source of truth, lets you validate or transform input on every change, and makes the current value trivially readable from anywhere in the component — but it does mean a re-render on every keystroke.

An uncontrolled input, by contrast, is left to manage its own value internally in the DOM, and you read the current value only when you need it — typically at submit time — using a ref (inputRef.current.value) or by reading FormData from the form element. Uncontrolled inputs are simpler for basic cases and avoid re-rendering on every keystroke, but they make it harder to validate live or keep other UI in sync with the field's value as the user types.

A practical rule of thumb: use controlled inputs when you need live validation, conditional UI based on the current value, or to keep multiple fields in sync; use uncontrolled inputs for simple one-off fields, file inputs (which must be uncontrolled since browsers don't allow setting a file input's value programmatically), or performance-sensitive large forms where minimizing re-renders matters.

Field types and their quirks

Text and number inputs are the most straightforward controlled fields: value={text} onChange={e => setText(e.target.value)}. For a number input, event.target.value is still a string even though the input restricts what the user can type, so you typically parse it with Number(e.target.value) or parseFloat before storing it as a number in state, and you should guard against NaN from an empty or invalid string.

Checkboxes are controlled via the checked prop (not value) and read from event.target.checked rather than event.target.value. Radio buttons are controlled as a group: each radio shares the same name, and you control which one is checked by comparing the group's single state value against each radio's own value, with checked={selectedValue === thisValue}.

Select elements are controlled the same way as text inputs, value={selected} onChange={...}, and event.target.value gives the selected option's value. Textareas behave like text inputs in React (unlike raw HTML, where a textarea's content is its child text) — you control it with value and onChange just like an <input>.

File inputs are a special case: browsers do not allow JavaScript to set an <input type='file'> element's value for security reasons, so file inputs must always be uncontrolled — you read the chosen file(s) from event.target.files in the onChange handler, or from a ref, rather than trying to control the value.

Validation, error messages, dirty and touched state

Validation logic checks a field's current value against rules (required, min/max length, format, matching another field) and produces an error message when the rules fail. A common pattern is to store an errors object in state, keyed by field name, and derive it either on every change (live validation) or only on blur/submit (deferred validation) depending on how aggressive you want the feedback to feel.

'Touched' state tracks whether the user has interacted with a given field yet (typically set on the field's onBlur handler), and 'dirty' state tracks whether the field's value has changed from its initial value. These two concepts together let you avoid showing a 'this field is required' error before the user has even had a chance to type into it — a common and important UX refinement: show validation errors only for fields that are touched (or after a submit attempt), not for every empty field on initial render.

Error messages should be associated with their field for accessibility, typically using aria-describedby pointing at an element containing the error text, and aria-invalid={Boolean(error)} on the input itself, so assistive technology announces the error when the field receives focus or when the error appears.

Async submission and accessible, large-form architecture

Submitting a form usually involves an async operation — a network request — so the submit handler typically sets an isSubmitting flag, calls preventDefault, awaits the request, and then either shows a success state or populates a submission-level error, all while disabling the submit button (or showing a spinner) to prevent duplicate submissions during the request.

Accessible forms pair every input with a real <label> (via htmlFor/id, or by wrapping the input), use semantic input types (type='email', type='tel') so mobile keyboards and browser autofill work correctly, group related fields with <fieldset> and <legend> where appropriate, and ensure the whole form is operable by keyboard alone — Tab moves between fields in a sensible order, and Enter submits the form from a text field without requiring a mouse click on the submit button.

For large, multi-field forms, hand-rolling individual useState calls per field becomes unwieldy; the common architectural pattern is to consolidate all field values into a single state object (or reducer), derive errors/touched/dirty as parallel objects keyed by field name, and often reach for a form library (like React Hook Form or Formik) or a custom useForm hook that encapsulates this bookkeeping so individual field components stay simple.

Controlled vs uncontrolled field patterns

checked (not value) controls checkboxes and radios; value/onChange controls text, number, select, and textarea. File inputs cannot be controlled by React state — always read File objects from event.target.files.

// Controlled text input
const [name, setName] = useState("");
<input value={name} onChange={e => setName(e.target.value)} />

// Uncontrolled text input via ref
const nameRef = useRef<HTMLInputElement>(null);
<input ref={nameRef} defaultValue="" />
// later: nameRef.current?.value

// Controlled checkbox
const [agreed, setAgreed] = useState(false);
<input type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)} />

// Controlled radio group
<input type="radio" name="plan" value="pro" checked={plan === "pro"} onChange={() => setPlan("pro")} />

// Uncontrolled file input (always uncontrolled)
<input type="file" onChange={e => setFile(e.target.files?.[0] ?? null)} />

Code Examples

A fully controlled form: email and agreed are React state, both inputs are wired with matching value/checked and onChange, each input has an associated label for accessibility, and the submit button is disabled until the checkbox is checked. Expected result: The Sign up button stays disabled until the checkbox is checked; submitting logs the current email and agreed values.

<input id="agree" type="checkbox" checked={agreed} onChange={e => setAgreed(e.target.checked)} />

This example combines a single values state object for a multi-field controlled form, derived validation errors, per-field touched tracking so errors only show after a field has been blurred or the form submitted, async submission with an isSubmitting flag that disables the button and shows progress text, and accessible error wiring via aria-invalid and aria-describedby. handleSubmit forces every field's touched flag to true before checking for errors, ensuring that a submit attempt reveals all validation errors even for fields the user never blurred.

Common mistake: Showing errors.username whenever it exists, without checking touched.username first, would display 'Username is required' immediately on the very first render before the user has typed anything.

function SignupForm() {
  const [email, setEmail] = useState("");
  const [agreed, setAgreed] = useState(false);

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    console.log({ email, agreed });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        required
      />

      <label htmlFor="agree">
        <input
          id="agree"
          type="checkbox"
          checked={agreed}
          onChange={e => setAgreed(e.target.checked)}
        />
        I agree to the terms
      </label>

      <button type="submit" disabled={!agreed}>Sign up</button>
    </form>
  );
}
interface FormValues {
  username: string;
  age: string;
}

interface FormErrors {
  username?: string;
  age?: string;
}

function ProfileForm() {
  const [values, setValues] = useState<FormValues>({ username: "", age: "" });
  const [touched, setTouched] = useState<Record<string, boolean>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState<string | null>(null);

  const errors: FormErrors = {};
  if (values.username.trim().length === 0) {
    errors.username = "Username is required";
  }
  const ageNum = Number(values.age);
  if (values.age !== "" && (Number.isNaN(ageNum) || ageNum < 13)) {
    errors.age = "Age must be a number 13 or older";
  }

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;
    setValues(prev => ({ ...prev, [name]: value }));
  };

  const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
    setTouched(prev => ({ ...prev, [event.target.name]: true }));
  };

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setTouched({ username: true, age: true });
    if (Obje
…

Controlled input data flow

In a controlled input, React state is the single source of truth: every keystroke flows through state before it appears back in the input.

User types a character
          |
          v
   onChange fires with event
          |
          v
  setValues(prev => ({ ...prev, field: newValue }))
          |
          v
   Component re-renders
          |
          v
  <input value={values.field} /> now shows the new value

Important Rules

Use value + onChange for text, number, select, and textarea fields; use checked + onChange for checkboxes and radios.

File inputs must be uncontrolled — read chosen files from event.target.files rather than trying to set their value.

Track touched state per field (typically via onBlur) so validation errors don't appear before the user has interacted with a field.

On submit, force all fields to touched and re-check validation before proceeding, so errors surface even for fields the user never blurred.

Disable (or show a spinner on) the submit button while an async submission is in flight to prevent duplicate submissions.

Pair every input with a real, associated <label>, and wire validation errors to the input via aria-invalid and aria-describedby.

Where this shows up

Signup and login forms, checkout flows, settings pages, and multi-step wizards all depend on correctly combined controlled/uncontrolled fields, touched/dirty tracking, accessible error messaging, and async submission handling with disabled/loading submit buttons — getting any one of these wrong shows up immediately as a frustrating or broken user experience, like a submit button that can be double-clicked into double-charging a customer.

<input type="file" value={fileName} onChange={handleFileChange} />

<input type="file" onChange={e => setFile(e.target.files?.[0] ?? null)} />

Browsers refuse to let JavaScript set a file input's value for security reasons, so passing a value prop to a file input either does nothing or throws a React warning; file inputs must stay uncontrolled and be read via files on change.

{errors.username && <p>{errors.username}</p>} // shown with no touched check

{touched.username && errors.username && <p>{errors.username}</p>}

Without gating on touched (or a submit attempt), a required-field error appears immediately when the form first renders, before the user has had any chance to fill it in — an aggressive and confusing UX.

[multiple-choice] Why must a file input in React always be uncontrolled? A. Browsers do not allow JavaScript to set a file input's value programmatically, for security reasons B. React does not support onChange on file inputs C. File inputs cannot have a name attribute D. TypeScript does not define a type for file input events

A. Browsers do not allow JavaScript to set a file input's value programmatically, for security reasons — For security reasons, browsers block scripts from setting the value of a file input, so React cannot control it the way it controls text or checkbox inputs — the chosen files must instead be read from event.target.files.

[multiple-choice] What is the purpose of tracking 'touched' state on a form field? A. To show validation errors only after the user has interacted with that field, rather than immediately on render B. To disable the field after it has been filled in once C. To automatically submit the form when every field has been touched D. To track which fields have been styled with CSS

A. To show validation errors only after the user has interacted with that field, rather than immediately on render — Touched state (usually set on blur) lets a form defer showing validation errors until the user has actually interacted with a field, avoiding the poor UX of showing 'required' errors on every empty field the instant the form first renders.

[true-false] True or false: checkboxes in React are controlled using the value prop, the same as text inputs. A. True B. False

B. False — False. Checkboxes (and radios) are controlled via the checked boolean prop and read from event.target.checked, not the value prop used by text, number, select, and textarea fields.

Build a controlled <select> for choosing a shipping method ('standard' | 'express') with state selectedShipping, and a piece of conditional text below it that reads 'Arrives in 5-7 days' for standard and 'Arrives in 1-2 days' for express.

const [selectedShipping, setSelectedShipping] = useState<'standard' | 'express'>('standard'); <select value={selectedShipping} onChange={e => setSelectedShipping(e.target.value as 'standard' | 'express')} > <option value="standard">Standard</option> <option value="express">Express</option> </select> <p>{selectedShipping === 'standard' ? 'Arrives in 5-7 days' : 'Arrives in 1-2 days'}</p>

Control the select with value={selectedShipping} and onChange={e => setSelectedShipping(e.target.value)}, then use a ternary based on selectedShipping for the arrival text.