The BookingForm from the previous lesson works, but it accepts anything: you can submit it without choosing a bike, with a date from last year, with zero hours, or without accepting the terms. A form that doesn't validate isn't finished. This lesson completes the other half of the story with two topics that go hand in hand: uncontrolled components, the alternative where the DOM holds the value and it's only read on submit, and validation, both the kind the browser offers out of the box and the kind you write yourself in JavaScript. You'll learn to decide when to validate without being annoying — the balance between warning early and not shouting at someone who's still typing —, to write validation as a pure function that's easy to test, and to show errors where they matter: right next to the field that triggered them.

Contents

  1. The two ways to store a field's value
  2. Uncontrolled components: the DOM is in charge
  3. defaultValue and defaultChecked
  4. Reading values with FormData
  5. useRef as an alternative
  6. Comparison: controlled vs. uncontrolled
  7. The special case of <input type="file">
  8. Native browser validation
  9. Validation in JavaScript: a pure function
  10. When to validate and the concept of a "touched" field
  11. Showing errors and blocking submission
  12. CicloUrbano: a validated BookingForm
  13. Form and schema libraries

  1. The two ways to store a field's value

There are only two possible answers to the question "where does what the user typed live?"

Approach Who stores the value How it's read How to set an initial value
Controlled React state From state, always available useState(initialValue)
Uncontrolled The DOM node By reading it explicitly (FormData or ref) defaultValue / defaultChecked

You've already mastered the first one. The second isn't "the old way" or a bad practice: it's HTML's native way, and in certain cases it's the right choice. The key is understanding what you gain and what you lose with each.

flowchart TD
    A["Someone types into a field"] --> B{"Controlled?"}
    B -- "Yes" --> C["onChange updates the state"] --> D["New render"] --> E["The field shows the state"]
    B -- "No" --> F["The DOM stores the value<br/>React doesn't know"] --> G["Read only on submit"]

  1. Uncontrolled components: the DOM is in charge

An uncontrolled field is simply a field without value or onChange: it behaves like plain old HTML.

function SimpleForm() {
  function handleSubmit(event) {
    event.preventDefault();
    // The value is read NOW, not before
    const data = new FormData(event.target);
    console.log(data.get('model'));
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="model">Model</label>
      <input id="model" name="model" type="text" />
      <button type="submit">Save</button>
    </form>
  );
}

Notice what's missing: no useState, no onChange, no value. The component doesn't re-render as you type, because nothing changes in React.

And notice what is there and is now essential: the name attribute on every field. In a controlled form, name was a convenience for the generic handler; here it's the only way to identify the field when reading it.

What you gain:

  • Less code. A ten-field form doesn't need ten pieces of state.
  • Zero renders while typing. On huge forms this can be noticeable.
  • Interoperability. It fits well with non-React code and third-party libraries that manipulate the DOM.

What you lose:

  • You can't react to what's typed. No real-time validation, character counters, previews, or on-the-fly transformations.
  • You can't disable the button until the form is valid, because you don't know what it contains.
  • Resetting and filling it programmatically requires touching the DOM.

  1. defaultValue and defaultChecked

An uncontrolled field can also start with a value. But you don't use value: that would turn it into a controlled one (and trigger the frozen-field warning you saw in 03-04).

{/* ✔ Initial value of an UNCONTROLLED field */}
<input name="hours" type="number" defaultValue={2} />
<textarea name="notes" defaultValue="No notes" />
<select name="type" defaultValue="urbana">
  <option value="urbana">Urban</option>
  <option value="electrica">Electric</option>
</select>

{/* For checkboxes and radios, defaultChecked */}
<input name="terms" type="checkbox" defaultChecked />
<input name="status" type="radio" value="disponible" defaultChecked />
Prop Controlled field Uncontrolled field
Text, textarea, dropdown value (+ onChange) defaultValue
Checkbox and radio checked (+ onChange) defaultChecked

There's a detail that surprises people: defaultValue only applies on the first render. If you change that prop later, the field doesn't update, because its value is now managed by the DOM. If you need to change the value from outside after the component mounts, that field has to be controlled.

  1. Reading values with FormData

FormData is a browser API — not a React one — that collects every field of a form based on its name attribute. It's the cleanest way to read an uncontrolled form.

// src/components/IncidentForm.jsx

/**
 * Incident report for a bike, with UNCONTROLLED fields.
 * Props:
 *  - bikes (array, optional, defaults to [])
 *  - onRegister (function, optional): receives the incident object
 */
function IncidentForm({ bikes = [], onRegister }) {
  function handleSubmit(event) {
    event.preventDefault();

    // event.target is the <form>; FormData collects all its fields that have a name
    const form = event.target;
    const data = new FormData(form);

    const incident = {
      bicicletaId: data.get('bicicletaId'),
      description: data.get('description'),
      // get() ALWAYS returns a string (or null): you have to convert it
      urgency: Number(data.get('urgency')),
      // An unchecked checkbox does NOT appear in FormData: get() returns null
      blocksUse: data.get('blocksUse') === 'on'
    };

    if (onRegister) {
      onRegister(incident);
    }

    form.reset();   // in an UNCONTROLLED form, reset() is genuinely correct
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="bicicletaId">Bike</label>
      <select id="bicicletaId" name="bicicletaId" defaultValue="">
        <option value="">— Choose a bike —</option>
        {bikes.map((bike) => (
          <option key={bike.id} value={bike.id}>
            {bike.model} ({bike.id})
          </option>
        ))}
      </select>

      <label htmlFor="description">Description</label>
      <textarea id="description" name="description" rows={3} />

      <label htmlFor="urgency">Urgency (1-5)</label>
      <input id="urgency" name="urgency" type="number" min={1} max={5} defaultValue={3} />

      <label>
        <input name="blocksUse" type="checkbox" /> Prevents the bike from being used
      </label>

      <button type="submit">Register incident</button>
    </form>
  );
}

export default IncidentForm;

Three details about FormData that cause bugs if you don't know them:

Behavior Consequence
get() returns a string or null Numbers have to be converted with Number()
An unchecked checkbox doesn't appear get('blocksUse') returns null, not false
Only collects fields with name A field with an id but no name is silently left out

And two very practical shortcuts:

// Turn the whole form into a plain object in one line
const obj = Object.fromEntries(new FormData(form));

// Collect all the values of a repeated field (checkboxes sharing the same name)
const types = new FormData(form).getAll('types');   // array of strings

Here form.reset() is genuinely correct: since the DOM holds the value, clearing it is exactly what's needed. On a controlled form it would be pointless, because React would put the state's values right back.

  1. useRef as an alternative

There's another way to read an uncontrolled field: keeping a reference to the DOM node.

import { useRef } from 'react';

function QuickSearch({ onSearch }) {
  const searchField = useRef(null);   // starts out empty

  function handleSubmit(event) {
    event.preventDefault();
    onSearch(searchField.current.value);   // direct access to the node
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={searchField} type="text" name="search" />
      <button type="submit">Search</button>
    </form>
  );
}

useRef creates an object with a current property; when you pass it as an element's ref prop, React places the real DOM node there. From that point on, searchField.current is the <input>, with all its methods and properties.

When FormData, and when useRef?

Need Tool
Read all fields on submit FormData: less code, no refs to maintain
Read a single standalone field Either one
Focus a field, select its text, scroll to it useRef: these are actions on the node, not reads
Integrate an external library that needs the node useRef

useRef is much more than a way to read fields: it's React's controlled escape hatch into the DOM, and it also stores values that persist between renders without triggering one. It's covered in depth in useRef Hook and DOM Access; for now it's enough to know it exists and what it's used for in forms.

  1. Comparison: controlled vs. uncontrolled

Criterion Controlled Uncontrolled
Where the value lives React state DOM node
Field props value + onChange defaultValue (nothing else)
How it's read From state, at any time FormData or ref, when read
Renders while typing One per keystroke None
Real-time validation Yes No
Disable submit if invalid Yes No (only with native validation)
Transform while typing Yes No
Change the value from outside Yes Not without touching the DOM
Amount of code More Less
Ease of testing High: state is enough Medium: you have to simulate the DOM
File fields Impossible Mandatory

Criteria for choosing, in order of importance:

  1. Do you need to react to what's typed? Live validation, character counter, a button that enables itself, previews, fields that depend on others. → Controlled.
  2. Is it a file field?Uncontrolled, mandatorily (section 7).
  3. Is it a large, simple form that's only read on submit? A twenty-field sign-up with no interdependencies. → Uncontrolled is a reasonable option.
  4. Does it integrate with external code that touches the DOM?Uncontrolled.
  5. When in doubtControlled. It's React's default, and it leaves the door open to adding behavior later.

In CicloUrbano we'll stick with controlled fields for BookingForm, precisely because we want to validate while it's being filled in.

  1. The special case of <input type="file">

File fields are the one absolute exception: they're always uncontrolled, no alternative.

function PhotoUpload({ onSelectPhoto }) {
  function handleChange(event) {
    const file = event.target.files[0];   // a FileList, not a string

    if (!file) {
      return;
    }

    console.log(file.name, file.size, file.type);
    onSelectPhoto(file);
  }

  return (
    <label>
      Photo of the damage:{' '}
      <input type="file" accept="image/*" onChange={handleChange} />
    </label>
  );
}

The reason is security: if React could set the value of a file field, a malicious page could write a disk path there and upload a file without anyone choosing it. That's why the browser forbids assigning that value from code: only the user can change it by picking a file.

The practical consequences:

  • Don't put value on an <input type="file">. React will warn you.
  • The value is read from event.target.files, a FileList object similar to an array. With multiple, it holds several files: Array.from(event.target.files).
  • You can use onChange to react to the selection: that doesn't make it controlled, because you're not imposing the value.
  • To clear it, the usual approach is resetting the form or changing the element's key so React recreates it.

  1. Native browser validation

HTML ships with validation out of the box, no JavaScript required. It's free, and it's worth taking advantage of.

<form onSubmit={handleSubmit}>
  <input name="email" type="email" required />
  <input name="hours" type="number" min={1} max={24} step={1} required />
  <input name="code" type="text" pattern="[A-Z]{3}-[0-9]{3}" title="Format: ABC-123" />
  <input name="startDate" type="datetime-local" required />
  <button type="submit">Submit</button>
</form>
Attribute What it checks
required That the field isn't empty (or is checked, for a checkbox)
type="email" That the text looks like an email address
type="url" That the text looks like a URL
min / max Range for numbers and dates
step Valid increments: step={0.5}, step={1}
minLength / maxLength Text length
pattern A regular expression the value must match
title The help text the browser shows when pattern fails

If any field fails, the browser blocks submissiononSubmit doesn't even run — and shows a bubble with a message.

Its limits, which are real

Limit Detail
Uncontrollable messages The browser sets the text and the language; the bubble's style can't be changed
Inconsistent look Every browser shows it differently
Only one error at a time Shows the first invalid field, not all of them
Doesn't cover business rules "The date can't be in the past," "this bike is already rented": impossible
No cross-field validation "The return must be after the pickup" needs JavaScript
Can be bypassed Just manipulate the HTML from the browser's dev tools
Limited accessibility The bubble isn't always announced well by screen readers

That last point about bypassing the filter has a consequence you must never forget: client-side validation is for the convenience of whoever fills in the form, not for security. The server must always re-validate everything it receives.

noValidate: turning off the native version

When you write your own validation and want to control every message, you turn off the browser's with the noValidate attribute on the <form>:

<form onSubmit={handleSubmit} noValidate>

This is standard in React apps with their own validation: the required, min, and max attributes stay in the markup — because they communicate useful information to assistive technologies — but you supply the messages.

  1. Validation in JavaScript: a pure function

The best way to validate is a pure function: it receives the data and returns an errors object, without touching state, reading the DOM, or causing side effects.

// src/utils/validateBooking.js

const MAX_HOURS = 24;

/**
 * Validates a CicloUrbano booking's data.
 *
 * @param {Object} data - { bicicletaId, startDate, hours, terms }
 * @param {Array}  bikes - the catalogue, to check availability
 * @returns {Object} an object with a message for each field with an error.
 *                   With no errors, returns an empty object {}.
 */
export function validateBooking(data, bikes = []) {
  const errors = {};

  // --- Bike: required and available ---
  if (!data.bicicletaId) {
    errors.bicicletaId = 'Choose a bike.';
  } else {
    const bike = bikes.find((b) => b.id === data.bicicletaId);

    if (!bike) {
      errors.bicicletaId = 'The selected bike does not exist.';
    } else if (bike.status !== 'disponible') {
      errors.bicicletaId = `${bike.model} isn't available right now.`;
    }
  }

  // --- Start date: required and not before right now ---
  if (!data.startDate) {
    errors.startDate = 'Say when the booking starts.';
  } else {
    const start = new Date(data.startDate);

    if (Number.isNaN(start.getTime())) {
      errors.startDate = 'The date has an invalid format.';
    } else if (start.getTime() < Date.now()) {
      errors.startDate = 'A booking can\'t start in the past.';
    }
  }

  // --- Hours: an integer between 1 and 24 ---
  const hours = Number(data.hours);

  if (data.hours === '' || Number.isNaN(hours)) {
    errors.hours = 'Say how many hours you want the bike for.';
  } else if (!Number.isInteger(hours)) {
    errors.hours = 'Hours must be a whole number.';
  } else if (hours < 1) {
    errors.hours = 'The minimum booking is 1 hour.';
  } else if (hours > MAX_HOURS) {
    errors.hours = `The maximum booking is ${MAX_HOURS} hours.`;
  }

  // --- Terms: must be accepted ---
  if (!data.terms) {
    errors.terms = 'You must accept the terms of use.';
  }

  return errors;
}

Why this approach is the right one:

  • It's pure. Given the same inputs it always returns the same output. No state, no hidden dates, no surprises.
  • It's testable without React. It's plain JavaScript: you pass it objects and check the result. When you reach Module 9 you'll see this kind of function is the easiest to cover with tests.
  • It returns an object keyed by field, not a boolean or a loose list. That way the form knows where to put each message.
  • A single error per field, the first one detected. Chaining else if avoids overwhelming the field with three messages at once.
  • Business rules included. Checking that the bike is 'disponible' is something no HTML attribute can do.
  • It lives in src/utils/, not inside the component: it can be reused on another screen and, eventually, on the server.

Checking whether the form is valid then comes down to a single line:

const errors = validateBooking(data, bikes);
const isValid = Object.keys(errors).length === 0;

  1. When to validate and the concept of a "touched" field

Validating is easy; validating at the right moment is what separates a pleasant form from an unbearable one.

Imagine an email field validated on every keystroke. Type the first letter, "a", and it turns red: "This email isn't valid." Of course it isn't: there are still fifteen characters to go. The message is correct and the experience is terrible.

Moment Advantage Drawback Recommended for
While typing (onChange) Immediate feedback Shouts too early on empty or half-finished fields Passwords with requirements, counters, fields already fixed once
On blur (onBlur) The person is done with that field The error takes a moment to appear The default moment
On submit (onSubmit) Never annoying while filling in All errors appear at once at the end Last line of defense, always mandatory

The strategy well-built applications use combines all three:

  1. On blur, the field is marked "touched" and its error is shown if it has one.
  2. While typing, only the error of fields that are already touched gets updated. That way, when someone is fixing an error, they see the message disappear the moment it's fixed.
  3. On submit, every field is marked touched and all pending errors are shown.

"Touched" fields

A field is touched once the person has interacted with it and left it. It's stored in a second piece of state, alongside the data:

const [touched, setTouched] = useState({});   // { bicicletaId: true, hours: true, … }

function handleBlur(event) {
  const { name } = event.target;
  setTouched((previous) => ({ ...previous, [name]: true }));
}

And the rule for showing a message is the conjunction of two conditions:

{touched.hours && errors.hours && <p className="error">{errors.hours}</p>}

On submit, they're all marked at once:

function markAllTouched(data) {
  const all = {};
  Object.keys(data).forEach((field) => {
    all[field] = true;
  });
  return all;
}
flowchart TD
    A["The person types"] --> B["The data updates"]
    B --> C["Errors are recalculated<br/>(derived value)"]
    C --> D{"Is the field touched?"}
    D -- "No" --> E["Nothing shown yet"]
    D -- "Yes" --> F["The field's message is shown"]
    G["The person leaves the field (blur)"] --> H["The field becomes touched"] --> D
    I["Form submission"] --> J["Every field becomes touched"] --> D

An important conceptual point: errors are not state. They're computed with validateBooking(data, bikes) on every render, from the data. Storing them in a useState would create a second source of truth that you'd have to remember to update on every change. What genuinely is state are the data and the touched fields, because they can't be derived from anything. It's the distinction from lesson 02-04, applied to validation.

  1. Showing errors and blocking submission

Three design decisions worth making consciously.

Where the message goes

Right next to the field that triggered it, never in a list at the top or bottom. Whoever reads the message needs to know immediately which field to fix, without having to search.

<div className={styles.field}>
  <label htmlFor="hours">Duration (hours)</label>
  <input id="hours" name="hours" type="number" value={formData.hours} onChange={handleChange} onBlur={handleBlur} />
  {showError('hours') && <p className={styles.error}>{errors.hours}</p>}
</div>

Should you disable the submit button?

It's tempting to set disabled={!isValid}, and it's worth thinking it through:

Approach For Against
Disabled button Clearly prevents an invalid submission Doesn't explain why; whoever sees it doesn't know what's missing. Disabled buttons are problematic with screen readers
Active button + validate on submit Pressing it shows all the errors, explained Allows one failed attempt
Mixed approach Active button, and on submit every field is marked and errors are shown The recommended one

The practical recommendation: leave the button active and validate on submit, showing every message. If your design requires disabling it, always add visible text explaining what's missing.

Styles and signals

A field with an error should stand out through more than one signal: a colored border, an icon, and the message text. Color alone isn't enough — part of the population can't distinguish red from green — and that idea, together with the correct way to associate the message with the field so a screen reader announces it (aria-invalid, aria-describedby, role="alert"), is developed in the next lesson, Accessibility in Interactive Components. Here we deal with the logic; there, with making sure it reaches everyone.

  1. CicloUrbano: a validated BookingForm

Let's bring it all together on top of the previous lesson's form.

// src/components/BookingForm.jsx
import { useState } from 'react';
import { cx } from '../utils/classNames.js';
import { validateBooking } from '../utils/validateBooking.js';
import styles from './BookingForm.module.css';

const INITIAL_DATA = {
  bicicletaId: '',
  startDate: '',
  hours: 2,
  terms: false
};

/**
 * Booking-creation form for CicloUrbano, with validation.
 * Props:
 *  - bikes (array, optional, defaults to []): the full catalogue
 *  - userId (string, optional, defaults to 'usr-01')
 *  - onCreateBooking (function, optional): receives the validated Booking object
 *
 * State: `formData` (what's being typed) and `touched` (which fields have been interacted with).
 * ERRORS are not state: they're derived from `formData` on every render.
 */
function BookingForm({ bikes = [], userId = 'usr-01', onCreateBooking }) {
  const [formData, setFormData] = useState(INITIAL_DATA);
  const [touched, setTouched] = useState({});

  // Derived values
  const errors = validateBooking(formData, bikes);
  const isValid = Object.keys(errors).length === 0;
  const available = bikes.filter((bike) => bike.status === 'disponible');
  const chosenBike = bikes.find((bike) => bike.id === formData.bicicletaId);
  const total = chosenBike ? chosenBike.pricePerHour * Number(formData.hours || 0) : 0;

  // An error is only shown once its field has been touched
  function showError(field) {
    return Boolean(touched[field] && errors[field]);
  }

  function handleChange(event) {
    const { name, type, value, checked } = event.target;

    let finalValue = value;
    if (type === 'checkbox') {
      finalValue = checked;
    } else if (type === 'number') {
      finalValue = value === '' ? '' : Number(value);
    }

    setFormData((previous) => ({ ...previous, [name]: finalValue }));
  }

  function handleBlur(event) {
    const { name } = event.target;
    setTouched((previous) => ({ ...previous, [name]: true }));
  }

  function handleSubmit(event) {
    event.preventDefault();

    // On submit, every field becomes touched: every error becomes visible
    const allTouched = {};
    Object.keys(INITIAL_DATA).forEach((field) => {
      allTouched[field] = true;
    });
    setTouched(allTouched);

    if (!isValid) {
      return;   // nothing is submitted while there are errors
    }

    const booking = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: formData.bicicletaId,
      user: userId,
      startDate: formData.startDate,
      hours: Number(formData.hours),
      status: 'activa'
    };

    if (onCreateBooking) {
      onCreateBooking(booking);
    }

    setFormData(INITIAL_DATA);
    setTouched({});
  }

  return (
    // noValidate: we turn off the browser's bubbles and use our own messages
    <form className={styles.form} onSubmit={handleSubmit} noValidate>
      <h2>New booking</h2>

      <div className={styles.field}>
        <label htmlFor="bicicletaId">Bike</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          required
          value={formData.bicicletaId}
          onChange={handleChange}
          onBlur={handleBlur}
          className={cx(styles.control, showError('bicicletaId') && styles.invalid)}
        >
          <option value="">— Choose a bike —</option>
          {available.map((bike) => (
            <option key={bike.id} value={bike.id}>
              {bike.model} · €{bike.pricePerHour.toFixed(2)}/h
            </option>
          ))}
        </select>
        {showError('bicicletaId') && (
          <p className={styles.error}>{errors.bicicletaId}</p>
        )}
      </div>

      <div className={styles.field}>
        <label htmlFor="startDate">Booking start</label>
        <input
          id="startDate"
          name="startDate"
          type="datetime-local"
          required
          value={formData.startDate}
          onChange={handleChange}
          onBlur={handleBlur}
          className={cx(styles.control, showError('startDate') && styles.invalid)}
        />
        {showError('startDate') && (
          <p className={styles.error}>{errors.startDate}</p>
        )}
      </div>

      <div className={styles.field}>
        <label htmlFor="hours">Duration (hours)</label>
        <input
          id="hours"
          name="hours"
          type="number"
          min={1}
          max={24}
          step={1}
          required
          value={formData.hours}
          onChange={handleChange}
          onBlur={handleBlur}
          className={cx(styles.control, showError('hours') && styles.invalid)}
        />
        {showError('hours') && <p className={styles.error}>{errors.hours}</p>}
      </div>

      <div className={styles.checkboxField}>
        <label>
          <input
            name="terms"
            type="checkbox"
            checked={formData.terms}
            onChange={handleChange}
            onBlur={handleBlur}
          />{' '}
          I accept CicloUrbano's terms of use
        </label>
        {showError('terms') && (
          <p className={styles.error}>{errors.terms}</p>
        )}
      </div>

      {chosenBike && isValid && (
        <p className={styles.total}>
          {chosenBike.model} · {formData.hours} h ·{' '}
          <strong>€{total.toFixed(2)}</strong>
        </p>
      )}

      <button type="submit" className={styles.submit}>
        Create booking
      </button>
    </form>
  );
}

export default BookingForm;

The resulting behavior, step by step:

Action What happens
The form opens No errors: nothing is touched yet
The dropdown opens and closes without choosing anything On blur: "Choose a bike."
A bike is chosen The message disappears as soon as the value is valid
0 is typed into hours and the field is left "The minimum booking is 1 hour."
It's corrected to 2 The message disappears while typing, because the field was already touched
"Create booking" is pressed with everything empty All four messages appear at once and nothing is submitted
It submits successfully The parent receives the Booking; the form and the touched fields reset

And a detail that illustrates the value of business rules: the dropdown only lists disponible bikes, but validateBooking checks it again. If that bike became rented while the form was being filled in, the message would be "Electric Pro isn't available right now." No HTML attribute can do that.

  1. Form and schema libraries

In projects with lots of forms, the code from this lesson starts repeating itself. The ecosystem has two families of tools worth knowing by name:

Tool What it brings
React Hook Form Manages values, touched fields, and errors with mostly uncontrolled fields, cutting down renders and boilerplate a lot
Formik The classic option, built on controlled components; very common in existing code
Zod Defines a data schema and validates against it, generating the messages; integrates with the ones above
Yup An alternative to Zod, older and also widely used

We won't go into them: they all rest on the concepts you've just learned — controlled and uncontrolled, touched fields, per-field errors, validation on submit — and adopting them without understanding the mechanism leaves you unable to debug them when something breaks. When you land on a project using React Hook Form with Zod, you'll recognize every piece.

Common Mistakes and Tips

  • Putting value on an uncontrolled field. It turns it into a controlled one and freezes it. For initial values, use defaultValue / defaultChecked.
  • Forgetting the name attribute in an uncontrolled form. FormData ignores fields without a name: the value doesn't show up, and there's no error at all.
  • Expecting FormData.get() to return numbers or booleans. It always returns a string or null. Convert explicitly, and remember that an unchecked checkbox doesn't appear.
  • Putting value on an <input type="file">. It's impossible for security reasons; the value is read from event.target.files.
  • Storing errors in state. They're a value derived from the data. Storing them creates a second source of truth that drifts out of sync.
  • Validating on every keystroke from the first character. It's incredibly annoying. Show the error only once the field is "touched."
  • Validating only while typing and not on submit. Whoever never touches a field never sees its error. Submission must mark every field as touched.
  • Relying only on client-side validation. It can be bypassed from the browser's dev tools. The server always has to validate.
  • Disabling the button without explaining why. It leaves people with no clue what's missing. Prefer validating on submit and showing the messages.
  • Using pattern with complicated regular expressions for email. None of them are fully correct. Use type="email" and confirm with a real submission.
  • Placing the error message far from the field. It should sit immediately below or beside it.
  • Mixing native and custom validation without noValidate. The browser blocks submission before your onSubmit runs, and your messages never get shown.
  • Tip: write validation as a pure function in src/utils/. It's testable without React, reusable, and documents the business rules in one place.
  • Tip: one message per field, and in a useful tone. "The minimum booking is 1 hour" explains what to do; "Invalid value" doesn't.
  • Tip: keep required, min, and max in the markup even when using noValidate. They communicate information to assistive technologies and document the field.

Exercises

Exercise 1

This form mixes the two approaches and has four problems. Identify them, explain each one's symptom, and decide whether it should be controlled or uncontrolled, justifying the choice.

function ReturnForm({ stations }) {
  const [station, setStation] = useState('');

  function handleSubmit(event) {
    event.preventDefault();
    const data = new FormData(event.target);
    console.log({
      station,
      kilometers: data.get('kilometers'),
      incident: data.get('incident'),
      photo: data.get('photo')
    });
    event.target.reset();
  }

  return (
    <form onSubmit={handleSubmit}>
      <select value={station}>
        {stations.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
      </select>

      <input type="number" defaultValue={0} />

      <input type="checkbox" name="incident" />

      <input type="file" name="photo" value="" />

      <button type="submit">Return</button>
    </form>
  );
}

Exercise 2

Write the pure function validateStation(data) in src/utils/validateStation.js, which validates registering a new CicloUrbano station. It must return an errors-by-field object with these rules:

  • name: required, between 3 and 40 characters, and it can't match (ignoring case and extra whitespace) the name of an existing station, passed in as the second parameter.
  • district: required, and must be one of 'Downtown', 'North', or 'Riverside'.
  • docks: an integer between 5 and 60.
  • inService: if false, there must be a reason of at least 10 characters.

Also write three hand-crafted test cases (input and expected output) that demonstrate it works.

Exercise 3

Turn the IncidentForm from section 4 — which is uncontrolled — into a controlled form with validation, applying the full touched-fields pattern. The rules:

  • bicicletaId: required.
  • description: required, minimum 15 characters, maximum 500, with a counter of remaining characters.
  • urgency: an integer between 1 and 5.
  • If blocksUse is checked, the urgency must be 4 or 5.

Explain why this last rule is impossible to express with native browser validation.

Solutions

Solution 1.

The four problems:

Problem Symptom
The <select> has value but no onChange The dropdown gets frozen on the initial option and React warns about a read-only field
The number field has no name FormData doesn't pick it up: data.get('kilometers') always returns null
<input type="file" value=""> Forbidden for security reasons; React warns. Also, FormData.get('photo') returns a File object, not a string
event.target.reset() on a controlled field The select doesn't reset: React puts the state's value right back on the next render

About the approach: it's best to make it entirely uncontrolled, since there's no need to react to what's typed. It's a return report that's read whole on submit, has no interdependent fields or real-time validation, and includes a file field, which is mandatorily uncontrolled. Mixing the two approaches in the same form is what produced three of the four bugs.

// src/components/ReturnForm.jsx

/**
 * A bike-return report. UNCONTROLLED form:
 * the DOM holds the values, read with FormData on submit.
 * Props:
 *  - stations (array, optional, defaults to [])
 *  - onReturn (function, optional): receives the return report
 */
function ReturnForm({ stations = [], onReturn }) {
  function handleSubmit(event) {
    event.preventDefault();

    const form = event.target;
    const data = new FormData(form);

    const report = {
      stationId: data.get('stationId'),
      kilometers: Number(data.get('kilometers')),
      // An unchecked checkbox doesn't appear in FormData
      incident: data.get('incident') === 'on',
      photo: data.get('photo')   // a File object, or an empty File if nothing was chosen
    };

    if (onReturn) {
      onReturn(report);
    }

    form.reset();   // correct: every field is uncontrolled
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="stationId">Return station</label>
      <select id="stationId" name="stationId" defaultValue="" required>
        <option value="">— Choose a station —</option>
        {stations.map((station) => (
          <option key={station.id} value={station.id}>
            {station.name} ({station.district})
          </option>
        ))}
      </select>

      <label htmlFor="kilometers">Kilometers ridden</label>
      <input id="kilometers" name="kilometers" type="number" min={0} defaultValue={0} />

      <label>
        <input name="incident" type="checkbox" /> There's an incident to report
      </label>

      <label htmlFor="photo">Photo (optional)</label>
      <input id="photo" name="photo" type="file" accept="image/*" />

      <button type="submit">Return</button>
    </form>
  );
}

export default ReturnForm;

Solution 2.

// src/utils/validateStation.js

const VALID_DISTRICTS = ['Downtown', 'North', 'Riverside'];
const MIN_DOCKS = 5;
const MAX_DOCKS = 60;

function normalize(text) {
  return String(text ?? '').trim().toLowerCase();
}

/**
 * Validates registering a new CicloUrbano station.
 *
 * @param {Object} data - { name, district, docks, inService, reason }
 * @param {Array} existing - already-registered stations, to avoid duplicates
 * @returns {Object} a message per field with an error; {} if everything is valid
 */
export function validateStation(data, existing = []) {
  const errors = {};

  // --- Name ---
  const name = String(data.name ?? '').trim();

  if (name === '') {
    errors.name = 'The station name is required.';
  } else if (name.length < 3) {
    errors.name = 'The name must be at least 3 characters long.';
  } else if (name.length > 40) {
    errors.name = 'The name can\'t exceed 40 characters.';
  } else if (existing.some((station) => normalize(station.name) === normalize(name))) {
    errors.name = `A station called "${name}" already exists.`;
  }

  // --- District ---
  if (!data.district) {
    errors.district = 'Choose a district.';
  } else if (!VALID_DISTRICTS.includes(data.district)) {
    errors.district = `The district must be one of: ${VALID_DISTRICTS.join(', ')}.`;
  }

  // --- Docks ---
  const docks = Number(data.docks);

  if (data.docks === '' || Number.isNaN(docks)) {
    errors.docks = 'Enter the number of docks.';
  } else if (!Number.isInteger(docks)) {
    errors.docks = 'Docks must be a whole number.';
  } else if (docks < MIN_DOCKS || docks > MAX_DOCKS) {
    errors.docks = `Docks must be between ${MIN_DOCKS} and ${MAX_DOCKS}.`;
  }

  // --- Reason, only if the station isn't entering service ---
  if (data.inService === false) {
    const reason = String(data.reason ?? '').trim();

    if (reason.length < 10) {
      errors.reason = 'Explain in at least 10 characters why it is not entering service.';
    }
  }

  return errors;
}

The three test cases:

const existing = [
  { id: 'est-01', name: 'Main Square', district: 'Downtown', docks: 20 },
  { id: 'est-02', name: 'North Park', district: 'North', docks: 15 }
];

// Case 1: everything valid -> {}
validateStation(
  { name: 'Old Market', district: 'Riverside', docks: 25, inService: true },
  existing
);

// Case 2: duplicate name with different case and spacing, docks out of range
validateStation(
  { name: '  main square  ', district: 'Downtown', docks: 100, inService: true },
  existing
);
// -> {
//      name: 'A station called "main square" already exists.',
//      docks: 'Docks must be between 5 and 60.'
//    }

// Case 3: out of service without enough of a reason
validateStation(
  { name: 'South Bridge', district: 'Riverside', docks: 12, inService: false, reason: 'roadworks' },
  existing
);
// -> { reason: 'Explain in at least 10 characters why it is not entering service.' }

The normalize function centralizes the comparison — trimming whitespace and lowercasing — so the rule applies the same way everywhere. And returning {} when everything is valid enables the one-line check: Object.keys(errors).length === 0.

Solution 3.

// src/components/IncidentForm.jsx
import { useState } from 'react';

const MAX_DESCRIPTION = 500;
const MIN_DESCRIPTION = 15;

const INITIAL_DATA = {
  bicicletaId: '',
  description: '',
  urgency: 3,
  blocksUse: false
};

function validateIncident(data) {
  const errors = {};

  if (!data.bicicletaId) {
    errors.bicicletaId = 'Choose the affected bike.';
  }

  const description = data.description.trim();
  if (description.length === 0) {
    errors.description = 'Describe the incident.';
  } else if (description.length < MIN_DESCRIPTION) {
    errors.description = `Describe the incident with at least ${MIN_DESCRIPTION} characters.`;
  } else if (description.length > MAX_DESCRIPTION) {
    errors.description = `The description can't exceed ${MAX_DESCRIPTION} characters.`;
  }

  const urgency = Number(data.urgency);
  if (data.urgency === '' || Number.isNaN(urgency)) {
    errors.urgency = 'Indicate the urgency level.';
  } else if (!Number.isInteger(urgency) || urgency < 1 || urgency > 5) {
    errors.urgency = 'Urgency must be a whole number between 1 and 5.';
  } else if (data.blocksUse && urgency < 4) {
    // CROSS-field validation between two fields
    errors.urgency = 'If the incident prevents the bike from being used, urgency must be 4 or 5.';
  }

  return errors;
}

/**
 * Incident report for a bike, controlled and validated.
 * Props:
 *  - bikes (array, optional, defaults to [])
 *  - onRegister (function, optional): receives the validated incident
 */
function IncidentForm({ bikes = [], onRegister }) {
  const [formData, setFormData] = useState(INITIAL_DATA);
  const [touched, setTouched] = useState({});

  const errors = validateIncident(formData);
  const isValid = Object.keys(errors).length === 0;
  const remaining = MAX_DESCRIPTION - formData.description.length;

  function showError(field) {
    return Boolean(touched[field] && errors[field]);
  }

  function handleChange(event) {
    const { name, type, value, checked } = event.target;

    let finalValue = value;
    if (type === 'checkbox') {
      finalValue = checked;
    } else if (type === 'number') {
      finalValue = value === '' ? '' : Number(value);
    }

    setFormData((previous) => ({ ...previous, [name]: finalValue }));
  }

  function handleBlur(event) {
    const { name } = event.target;
    setTouched((previous) => ({ ...previous, [name]: true }));
  }

  function handleSubmit(event) {
    event.preventDefault();

    const all = {};
    Object.keys(INITIAL_DATA).forEach((field) => {
      all[field] = true;
    });
    setTouched(all);

    if (!isValid) {
      return;
    }

    if (onRegister) {
      onRegister({ ...formData, description: formData.description.trim() });
    }

    setFormData(INITIAL_DATA);
    setTouched({});
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="bicicletaId">Bike</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          value={formData.bicicletaId}
          onChange={handleChange}
          onBlur={handleBlur}
        >
          <option value="">— Choose a bike —</option>
          {bikes.map((bike) => (
            <option key={bike.id} value={bike.id}>
              {bike.model} ({bike.id})
            </option>
          ))}
        </select>
        {showError('bicicletaId') && <p className="error">{errors.bicicletaId}</p>}
      </div>

      <div>
        <label htmlFor="description">Description</label>
        <textarea
          id="description"
          name="description"
          rows={3}
          maxLength={MAX_DESCRIPTION}
          value={formData.description}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        <small>{remaining} characters left.</small>
        {showError('description') && <p className="error">{errors.description}</p>}
      </div>

      <div>
        <label htmlFor="urgency">Urgency (1-5)</label>
        <input
          id="urgency"
          name="urgency"
          type="number"
          min={1}
          max={5}
          value={formData.urgency}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {showError('urgency') && <p className="error">{errors.urgency}</p>}
      </div>

      <label>
        <input
          name="blocksUse"
          type="checkbox"
          checked={formData.blocksUse}
          onChange={handleChange}
          onBlur={handleBlur}
        />{' '}
        Prevents the bike from being used
      </label>

      <button type="submit">Register incident</button>
    </form>
  );
}

export default IncidentForm;

Why the last rule is impossible natively: HTML's validation attributes are local to a single field. min={1} and max={5} only know about that <input>'s own value; there's no attribute that says "this field's minimum depends on whether that other checkbox is checked." It's a cross-field validation between two fields — in other words, a business rule — and that requires JavaScript. Notice, too, how effortlessly the pure-function approach absorbs it: it's just one more else if inside the urgency validation.

Conclusion

With this lesson, React forms are complete. You've seen the alternative to controlled components: uncontrolled ones, where the DOM holds the value, it's declared with defaultValue or defaultChecked, and it's read on submit with FormData — remembering that it returns strings, that unchecked checkboxes don't appear, and that it only collects fields with name — or with useRef, the direct path to the node that you'll study in depth in Module 5. You know how to choose between the two approaches with judgment: controlled whenever you need to react to what's typed, uncontrolled for large forms that are only read at the end, and mandatorily uncontrolled for <input type="file">, which for security reasons doesn't let code set its value.

On validation, you've learned that the browser offers a free first filter with required, min, max, type="email", and pattern, but that its messages can't be controlled, it only shows one error at a time, it can't express business rules or cross-field validations, and it can be bypassed — which is why the server must always validate. That's why serious validation is written as a pure function in src/utils/, one that receives the data and returns an object with a message per field: it's testable without React, it's reusable, and it concentrates the domain's rules in a single place. And above all you've learned when to validate: errors are always calculated, but only shown once the field is touched, with submission marking every field at once. Errors are not state; the data and the touched fields are.

CicloUrbano now has a BookingForm that won't let you create bookings in the past, with zero hours, on unavailable bikes, or without accepting the terms, and that says exactly what's wrong and where. One question remains: those messages are visible, but are they audible? A screen reader doesn't automatically connect a red paragraph with the field above it, an <article> with onClick can't be activated from the keyboard, and a status badge that's distinguished only by color leaves out anyone who can't perceive that color. All of that — and how to check it — is what closes out the module in Accessibility in Interactive Components.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved