CicloUrbano's catalogue already paints itself from the data, reacts to clicks, and adapts to each bike's status. But it's still a one-way street: the app shows information and collects none. Creating a booking means choosing a bike, specifying when it starts, how many hours it lasts, and accepting the terms — and that means forms. In React, forms have a quirk that throws people off at first: by default, an <input> keeps its own value inside the DOM, outside React's awareness, which collides head-on with the idea that the interface is a function of state. The solution is called a controlled component, and in this lesson you'll master it field by field — text, textarea, dropdown, checkboxes, radios, numbers and dates — you'll learn to manage an entire form with a single piece of state and a single handler, and you'll build CicloUrbano's BookingForm from start to finish.

Contents

  1. The conflict: two sources of truth
  2. What a controlled component is
  3. value + onChange step by step
  4. A value with no onChange: the frozen field
  5. Text fields and textarea
  6. The select dropdown, single and multiple
  7. Checkboxes with checked
  8. Radio button groups
  9. number and date: type conversion
  10. A single state for the whole form
  11. Submission: onSubmit and preventDefault
  12. CicloUrbano: BookingForm

  1. The conflict: two sources of truth

In HTML, form fields are elements with memory of their own. When you type into an <input>, the browser stores that text inside the DOM node and nobody else finds out:

<input type="text" id="model" value="Urban" />
<!-- As you type, the value changes inside the DOM. To read it: -->
<script>
  const text = document.getElementById('model').value;
</script>

That behavior is convenient on a static page and problematic in React, because it creates two places where the same information lives:

Source What it knows Problem
The DOM What the user has typed React never finds out about the change
React's state What React believes is there Can drift out of sync with the DOM

With two sources of truth, questions appear that have no clean answer: how do you enable the "Submit" button only once the field has content, if React doesn't know what's inside it? How do you uppercase the text as it's typed? How do you clear the form after submitting it?

React's answer is blunt: there should be only one source of truth, and it should be the state.

  1. What a controlled component is

A controlled component is a form field whose value is dictated by React's state. The field decides nothing: it only shows what the state tells it to and reports attempts to change it.

import { useState } from 'react';

function ModelSearch() {
  const [text, setText] = useState('');

  return (
    <input
      type="text"
      value={text}                                   // the state is in charge
      onChange={(event) => setText(event.target.value)}   // the field reports back
    />
  );
}

The full cycle, worth understanding properly because it's counterintuitive the first time:

flowchart TD
    A["The person presses a key"] --> B["onChange fires"]
    B --> C["The handler reads event.target.value"]
    C --> D["setText(newValue)<br/>updates the state"]
    D --> E["React renders again"]
    E --> F["The input receives value = new state"]
    F --> G["The letter appears on screen"]

The surprising part is the final step: the letter doesn't appear because you typed it, but because the state changed. If the handler didn't update the state, the keystroke would leave no trace no matter how many times you pressed it. The field is a window onto the state, not a storage box.

This is exactly interface = f(state) applied to forms, and every advantage flows from it:

Advantage Concrete example
One single place to check The value lives in state; no need to query the DOM
Real-time validation You can check it on every keystroke (lesson 03-05)
Transform as you type Uppercase, character limits, formatting
Reactive interface Disable the button, show a character counter, preview
Resetting is trivial setData(initialValues) and the form empties itself
Easy to test Changing state is the same as filling in the form

There's an alternative — letting the DOM hold the value and only reading it at the end — called an uncontrolled component. It has legitimate use cases and is covered in the next lesson, Form Validation and Uncontrolled Components. For 90% of the forms in a React application, the right answer is "controlled".

  1. value + onChange step by step

The two ingredients are inseparable. Let's break them down one at a time.

value: the state is in charge

<input type="text" value={text} />

It tells the field: "whatever you're showing, your content is this." On every render React checks the DOM's value and, if it doesn't match the prop, corrects it.

onChange: the field reports back

onChange={(event) => setText(event.target.value)}

Three important details about this line:

  • event.target is the DOM node that triggered the event: the <input>. As you learned in 03-01, it's different from currentTarget, though on a standalone field they coincide.
  • .value is always a text string, even on an <input type="number">. We'll come back to this in section 9.
  • In React, onChange fires on every keystroke, unlike the DOM's native change event, which only fires when the field loses focus. That difference is what makes this whole pattern possible.

The named handler

For anything beyond the trivial, extract the handler following the convention from 03-01:

import { useState } from 'react';

function ModelSearch({ onSearch }) {
  const [text, setText] = useState('');

  function handleTextChange(event) {
    const value = event.target.value;
    setText(value);

    if (onSearch) {
      onSearch(value);   // notify the parent on every keystroke
    }
  }

  return (
    <label>
      Search model:{' '}
      <input
        type="text"
        value={text}
        onChange={handleTextChange}
        placeholder="Urban, Electric…"
      />
    </label>
  );
}

export default ModelSearch;

Notice that here we do pass the reference (onChange={handleTextChange}), with no arrow function, because no extra arguments are needed: React already passes the event as the first parameter.

Transforming as you type

Since the value passes through your code before returning to the screen, you can modify it:

function handleCodeChange(event) {
  // Uppercase only, and no more than 8 characters
  const value = event.target.value.toUpperCase().slice(0, 8);
  setCode(value);
}

This is impossible with an uncontrolled field without manipulating the DOM by hand. It's one of the strong reasons to prefer controlled fields.

  1. A value with no onChange: the frozen field

This is mistake number one with forms in React, and the warning React shows is one of the few people read in full, because the symptom is alarming.

// ✘ WRONG: fixed value with no handler
<input type="text" value={text} />

Symptom: the field won't accept typing. You press keys and nothing happens, as if it were locked.

Cause: the value is tied to the state, and without onChange the state never changes. On every keystroke attempt React reimposes the previous value.

Console warning:

Warning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

The message itself lists the three valid ways out:

Intent Solution
The field should be editable Add onChange that updates the state
The field should be read-only on purpose Add readOnly alongside value
The field should have an initial value but be managed by the DOM Use defaultValue instead of value (uncontrolled field, lesson 03-05)
{/* Intentional read-only: the warning disappears */}
<input type="text" value={bike.id} readOnly />

There's a second warning, a sibling of the first, that shows up when the initial state is undefined or null:

Warning: A component is changing an uncontrolled input to be controlled.

It happens like this: on the first render, value={undefined} makes React treat the field as uncontrolled; as soon as the state receives a string, it becomes controlled, and React warns about the regime change. The fix is always the same: initialize the state with an empty string, never with undefined or null.

const [text, setText] = useState('');        // ✔ correct
const [text, setText] = useState();          // ✘ triggers the warning
const [text, setText] = useState(null);      // ✘ triggers the warning

  1. Text fields and textarea

Single-line fields all work the same way, no matter what type changes to:

<input type="text" value={name} onChange={handleChange} />
<input type="email" value={email} onChange={handleChange} />
<input type="password" value={password} onChange={handleChange} />
<input type="search" value={search} onChange={handleChange} />
<input type="tel" value={phone} onChange={handleChange} />

The type changes the on-screen keyboard on mobile and the browser's native validation, but the React pattern is identical.

The <textarea> does have one difference. In HTML, its content sits between the tags:

<!-- Classic HTML -->
<textarea rows="4">Initial text</textarea>

In React, no: it's treated like any other field, and the value goes in the value prop.

{/* ✔ React: the content goes in value */}
<textarea rows={4} value={comment} onChange={handleCommentChange} />

{/* ✘ Don't do this: React warns you to use the value prop */}
<textarea rows={4} onChange={handleCommentChange}>{comment}</textarea>

The reason is consistency: this way every field is read and written the same way, with no exceptions to remember.

A complete example with a character counter, showing how convenient it is to have the value in state:

import { useState } from 'react';

const MAX_LENGTH = 200;

function OperatorNote() {
  const [note, setNote] = useState('');
  const remaining = MAX_LENGTH - note.length;   // derived value, not state

  return (
    <div>
      <label htmlFor="operator-note">Operator note</label>
      <textarea
        id="operator-note"
        rows={4}
        maxLength={MAX_LENGTH}
        value={note}
        onChange={(event) => setNote(event.target.value)}
      />
      <p className={remaining < 20 ? 'notice' : ''}>
        {remaining} characters left.
      </p>
    </div>
  );
}

export default OperatorNote;

  1. The select dropdown, single and multiple

Here React clearly departs from HTML. In HTML, the selected option is marked with the selected attribute on the <option>:

<!-- Classic HTML -->
<select>
  <option value="urbana">Urban</option>
  <option value="electrica" selected>Electric</option>
</select>

In React, the value goes on the <select> and no <option> carries selected:

function StationSelector({ stations, stationId, onChange }) {
  return (
    <label>
      Pickup station:{' '}
      <select value={stationId} onChange={onChange}>
        <option value="">— Choose a station —</option>
        {stations.map((station) => (
          <option key={station.id} value={station.id}>
            {station.name} ({station.district})
          </option>
        ))}
      </select>
    </label>
  );
}

Key points:

  • The <select>'s value must match the value of some <option>. If it doesn't match any, the browser shows the first one and a hard-to-spot desync appears.
  • The empty <option value=""> is the usual trick for representing "nothing chosen yet." It pairs well with the validation from the next lesson.
  • Options generated with map need a key, like any list (lesson 03-03).
  • event.target.value is always a string. If your identifiers were numeric, you'd need conversions; with CicloUrbano's est-01-style ids there's no issue.

The multiple select

With multiple, the value stops being a string and becomes an array, and you have to read the selected options from the DOM:

import { useState } from 'react';

const TYPES = ['urbana', 'electrica', 'carga'];

function MultiTypeFilter() {
  const [types, setTypes] = useState([]);   // array, not a string

  function handleChange(event) {
    // event.target.selectedOptions is an array-like collection, not a real array
    const chosen = Array.from(event.target.selectedOptions, (option) => option.value);
    setTypes(chosen);
  }

  return (
    <>
      <select multiple value={types} onChange={handleChange} size={3}>
        {TYPES.map((type) => (
          <option key={type} value={type}>
            {type}
          </option>
        ))}
      </select>
      <p>Selected: {types.length > 0 ? types.join(', ') : 'none'}</p>
    </>
  );
}

export default MultiTypeFilter;

Array.from(collection, function) converts the DOM collection into a real array while applying the transformation at the same time, in a single pass. And notice types.length > 0 instead of types.length &&: the 0 trap from lesson 03-02 is still in force.

  1. Checkboxes with checked

A checkbox has no "text": it has a checked state. That's why it uses two different props:

Field Value prop Event property State type
text, textarea, select value event.target.value string
checkbox checked event.target.checked boolean
radio checked event.target.checked boolean per option
import { useState } from 'react';

function TermsAcceptance() {
  const [accepted, setAccepted] = useState(false);

  return (
    <label>
      <input
        type="checkbox"
        checked={accepted}
        onChange={(event) => setAccepted(event.target.checked)}
      />{' '}
      I accept CicloUrbano's terms of use
    </label>
  );
}

export default TermsAcceptance;

The classic mistake is writing setAccepted(event.target.value): on a checkbox, value defaults to "on", a string that's always truthy. The box would get checked and could never be unchecked again.

A group of independent checkboxes

When there are several related checkboxes, the natural approach is to store an object or an array:

import { useState } from 'react';

const STATUSES = ['disponible', 'alquilada', 'mantenimiento'];

function StatusFilter() {
  const [checkedStatuses, setCheckedStatuses] = useState({
    disponible: true,
    alquilada: false,
    mantenimiento: false
  });

  function handleChange(event) {
    const { name, checked } = event.target;
    // Copy of the previous object + overwrite of a single key
    setCheckedStatuses((previous) => ({ ...previous, [name]: checked }));
  }

  return (
    <fieldset>
      <legend>Show bikes with status…</legend>
      {STATUSES.map((status) => (
        <label key={status}>
          <input
            type="checkbox"
            name={status}
            checked={checkedStatuses[status]}
            onChange={handleChange}
          />{' '}
          {status}
        </label>
      ))}
    </fieldset>
  );
}

export default StatusFilter;

Here, for the first time, appears the piece that anchors section 10: [name]: checked, a computed property name. We'll go into detail there.

  1. Radio button groups

Radios are a special case: several fields share a single value. What groups them is the name attribute, which must be identical on every one, and each one declares which value it represents.

import { useState } from 'react';

const OPTIONS = [
  { value: 'urbana', label: 'Urban' },
  { value: 'electrica', label: 'Electric' },
  { value: 'carga', label: 'Cargo' }
];

function TypeRadioSelector() {
  const [type, setType] = useState('urbana');

  return (
    <fieldset>
      <legend>Bike type</legend>

      {OPTIONS.map((option) => (
        <label key={option.value}>
          <input
            type="radio"
            name="bikeType"                      // the SAME name on every one
            value={option.value}                 // what this radio represents
            checked={type === option.value}      // is this the chosen one?
            onChange={(event) => setType(event.target.value)}
          />{' '}
          {option.label}
        </label>
      ))}
    </fieldset>
  );
}

export default TypeRadioSelector;

The line you need to understand is checked={type === option.value}. Every radio asks itself: "am I the state's value?" Only one answers yes, and that guarantees, by construction, that two can never be checked at once.

In onChange we read event.target.value — not checked — because what we want to store is which one was chosen, not whether it's checked.

An accessibility detail that will be developed further in 03-06: grouping the radios in a <fieldset> with a <legend> isn't decorative. It's what lets a screen reader announce "Bike type, group, option 2 of 3."

  1. number and date: type conversion

Here's the silent trap of forms in React: event.target.value is ALWAYS a string, no matter the field's type.

<input type="number" value={hours} onChange={(e) => setHours(e.target.value)} />
// If you type 3, the state stores "3" (a string), not 3 (a number)

The consequences show up as soon as you do any math:

const hours = '3';           // what's actually in the state
hours * 4.0                  // 12  -> multiplication converts, this works
hours + 1                    // "31" -> ✘ addition concatenates strings
hours > 24                   // false -> compares "3" with 24, converts, works by luck

The fix is to convert inside the handler, so the state always stores the right type:

function handleHoursChange(event) {
  const value = event.target.value;

  // Empty field: store an empty string so the input stays controlled
  if (value === '') {
    setHours('');
    return;
  }

  setHours(Number(value));
}

Why the special case for empty? Because Number('') is 0, and if we converted without checking, clearing the field would write a 0 you could never delete. Storing the empty string keeps the field controlled and lets you empty it.

Field type What .value returns Recommended conversion
text, textarea, select string None
checkbox use .checked None (already a boolean)
number string, or '' if empty or invalid Number(value), handling '' separately
range string Number(value)
date string "2026-05-04" None to store; new Date(value) to compute
datetime-local string "2026-05-04T09:00" None: matches domain.js's format
time string "09:00" None
file .files, not .value Always uncontrolled (lesson 03-05)

There's good news about dates for CicloUrbano: the format <input type="datetime-local"> produces is exactly "2026-05-04T09:00", the exact same one the startDate field in domain.js uses. No conversion is needed to store it; you'll only need one to compare it, and that arrives in the next lesson.

number and date fields also accept attributes the browser respects: min, max and step. They help, but they're no guarantee: they can be bypassed. Real validation is the subject of 03-05.

  1. A single state for the whole form

With four fields, having four useState calls and four handlers starts getting noisy:

// Works, but doesn't scale
const [bicicletaId, setBicicletaId] = useState('');
const [startDate, setStartDate] = useState('');
const [hours, setHours] = useState(1);
const [terms, setTerms] = useState(false);

The idiomatic alternative is one state object and one generic handler:

const [formData, setFormData] = useState({
  bicicletaId: '',
  startDate: '',
  hours: 1,
  terms: false
});

function handleChange(event) {
  const { name, type, value, checked } = event.target;
  const finalValue = type === 'checkbox' ? checked : value;

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

Let's break down the two key lines.

The computed property name

{ ...previous, [name]: finalValue }

The brackets around name are JavaScript (ES6) syntax called a computed property name. It means: "use the contents of the name variable as the property name."

const name = 'hours';

{ [name]: 3 }    // -> { hours: 3 }    ✔ the name comes from the variable
{ name: 3 }      // -> { name: 3 }     ✘ the name is literally "name"

Combined with the ...previous spread, the full expression means: "copy everything that was there and replace only the property whose name matches the field's name." It's the immutable object update you learned in 02-04, applied to forms.

The name attribute

For this to work, every field must carry a name that matches its key in the state object:

<input name="startDate" value={formData.startDate} onChange={handleChange} />
<input name="hours" type="number" value={formData.hours} onChange={handleChange} />
<input name="terms" type="checkbox" checked={formData.terms} onChange={handleChange} />

If the name doesn't match any key, the handler silently adds a new key to the object, and the field you meant to update doesn't change. It's a bug as common as it is hard to spot: always check the names when a field doesn't respond.

The updater's functional form

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

The functional form is used — setFormData(fn) instead of setFormData(object) — for the reason you learned in 02-04: it guarantees starting from the most recent value even when several updates happen back to back. In a form with fields that affect each other, that guarantee prevents lost changes.

Also notice the parentheses wrapping the object: (previous) => ({ … }). Without them, JavaScript would interpret the braces as the function body and return undefined.

One state or several: how to decide

Situation Recommendation
1 or 2 independent fields One useState per field, more readable
3 or more fields submitted together One object and a generic handler
Fields validated and reset together One object
A field with very particular logic Its own useState, even if the rest lives in an object
Very large forms with complex logic useReducer, covered in useReducer Hook

  1. Submission: onSubmit and preventDefault

The handler goes on the <form>, not on the button

{/* ✔ CORRECT */}
<form onSubmit={handleSubmit}>
  <button type="submit">Book</button>
</form>

{/* ✘ INCOMPLETE: only works with a mouse click */}
<form>
  <button type="button" onClick={handleSubmit}>Book</button>
</form>

The difference isn't stylistic, it's functional. A <form> with onSubmit gets submitted in three different ways:

  1. Pressing the type="submit" button.
  2. Pressing Enter in any text field of the form.
  3. Through assistive technologies that trigger the form's submission.

With the handler on the button, only the first one works. The second is the one huge numbers of people use without even thinking about it, and its absence feels like the app is broken.

preventDefault is mandatory

function handleSubmit(event) {
  event.preventDefault();   // without this, the page reloads
  // … process the data
}

A form's native behavior is to send the data to the server and reload the page. In a React app that destroys all the state and restarts the app: the symptom is a flash and an empty form, as if nothing had happened. event.preventDefault(), which you already know from lesson 03-01, prevents it.

The full submission flow

flowchart TD
    A["Form submit<br/>(button or Enter key)"] --> B["handleSubmit(event)"]
    B --> C["event.preventDefault()"]
    C --> D["Build the object from the state's data"]
    D --> E["Notify the parent via a function prop"]
    E --> F["Reset the form's state"]

Resetting the form after submitting

Since the state is the only source of truth, clearing the form just means assigning the initial values. The clean pattern is to store those values in a constant:

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

function ExampleForm() {
  const [formData, setFormData] = useState(INITIAL_DATA);

  function handleSubmit(event) {
    event.preventDefault();
    // … process
    setFormData(INITIAL_DATA);   // the form clears itself
  }
  // …
}

Two warnings:

  • The constant goes outside the component. Inside it, it would get recreated on every render for no reason.
  • INITIAL_DATA must never be mutated. Since setFormData always creates new objects with spread, the original object stays intact and can be reused as many times as needed.

There's also event.target.reset(), the DOM's native method, but don't use it on a controlled form: it would clear the DOM without touching the state, and React would put the previous values right back on the next render. With controlled fields, you reset the state.

  1. CicloUrbano: BookingForm

It's time to bring it all together. The form has to let you choose an available bike, specify when the booking starts, how many hours it lasts, and accept the terms; on submission, it builds a Booking object shaped like domain.js's and hands it to the parent.

// src/components/BookingForm.jsx
import { useState } from 'react';
import styles from './BookingForm.module.css';

// Constant outside the component: it isn't recreated on every render
const INITIAL_DATA = {
  bicicletaId: '',
  startDate: '',
  hours: 2,
  terms: false
};

/**
 * Booking-creation form for CicloUrbano.
 * Props:
 *  - bikes (array, optional, defaults to []): the full catalogue
 *  - userId (string, optional, defaults to 'usr-01'): who's booking
 *  - onCreateBooking (function, optional): receives the constructed Booking object
 *
 * Every field is CONTROLLED: the `formData` state is the only source of truth.
 * Validation arrives in lesson 03-05.
 */
function BookingForm({ bikes = [], userId = 'usr-01', onCreateBooking }) {
  const [formData, setFormData] = useState(INITIAL_DATA);

  // Derived values: recomputed on every render
  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;
  const formattedTotal = total.toFixed(2);

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

    // Each field type reads its value from a different place
    let finalValue = value;
    if (type === 'checkbox') {
      finalValue = checked;
    } else if (type === 'number') {
      finalValue = value === '' ? '' : Number(value);
    }

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

  function handleSubmit(event) {
    event.preventDefault();   // without this, the page would reload

    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);   // the form clears itself
  }

  return (
    <form className={styles.form} onSubmit={handleSubmit}>
      <h2>New booking</h2>

      <div className={styles.field}>
        <label htmlFor="bicicletaId">Bike</label>
        <select
          id="bicicletaId"
          name="bicicletaId"
          value={formData.bicicletaId}
          onChange={handleChange}
        >
          <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>
      </div>

      <div className={styles.field}>
        <label htmlFor="startDate">Booking start</label>
        <input
          id="startDate"
          name="startDate"
          type="datetime-local"
          value={formData.startDate}
          onChange={handleChange}
        />
      </div>

      <div className={styles.field}>
        <label htmlFor="hours">Duration (hours)</label>
        <input
          id="hours"
          name="hours"
          type="number"
          min={1}
          max={24}
          step={1}
          value={formData.hours}
          onChange={handleChange}
        />
      </div>

      <div className={styles.checkboxField}>
        <label>
          <input
            name="terms"
            type="checkbox"
            checked={formData.terms}
            onChange={handleChange}
          />{' '}
          I accept CicloUrbano's terms of use
        </label>
      </div>

      {chosenBike && (
        <p className={styles.total}>
          {chosenBike.model} · {formData.hours || 0} h · <strong>€{formattedTotal}</strong>
        </p>
      )}

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

export default BookingForm;

A recap of the decisions made:

  • INITIAL_DATA with hours: 2 reproduces the canonical value from booking res-01 in domain.js, so the form suggests the usual duration.
  • A single state and a single handler for four fields of three different types, thanks to name and the computed property name.
  • The per-type conversion is centralized in handleChange: checked for the checkbox, Number for the numeric field (respecting the empty string), and value as-is for the rest.
  • available, chosenBike, total and formattedTotal are derived values. There isn't a single extra useState: storing them in state would expose them to falling out of sync, as you learned in 02-04.
  • The total only shows up if a bike is chosen, using the && from lesson 03-02.
  • htmlFor on every <label> points at the field's id. It isn't decoration: it makes clicking the label focus the field, and it's what lets a screen reader announce which field it is. This is developed further in Accessibility in Interactive Components.
  • crypto.randomUUID() generates the identifier the moment the booking is created, not during render. It's exactly the advice from the previous lesson about generating ids when the data is created.

App receives the bookings

// src/App.jsx (excerpt)
import { useState } from 'react';
import { bikes, stations, bookings as initialBookings } from './data/domain.js';
import BookingForm from './components/BookingForm.jsx';

function App() {
  const [bookings, setBookings] = useState(initialBookings);

  function handleCreateBooking(booking) {
    console.log('New booking:', booking);
    setBookings((previous) => [...previous, booking]);   // new array, no mutation
  }

  return (
    <main>
      <BookingForm bikes={bikes} onCreateBooking={handleCreateBooking} />
      <p>Bookings on record: {bookings.length}</p>
    </main>
  );
}

Fill in the form and submit it: an object with the exact same shape as res-01 shows up in the console, and the booking counter goes up. The form clears itself, because the state went back to INITIAL_DATA.

You can still submit a booking with no bike, with a date in the past, or without accepting the terms. That's deliberate: full validation is the subject of the next lesson.

About form libraries: large projects use tools like React Hook Form or Formik, which cut down on boilerplate and optimize renders. All of them build on the concepts from this lesson, so the right order is to learn the mechanism first and then, if the project calls for it, adopt the tool.

Common Mistakes and Tips

  • value with no onChange. The field becomes read-only and React warns about it. Add the handler, or readOnly if it's intentional.
  • Initializing state with undefined or null. It triggers the "changing an uncontrolled input to be controlled" warning. Use '' for text and false for checkboxes.
  • Reading event.target.value on a checkbox. It returns "on", which is always truthy: the box can never be unchecked. Use event.target.checked.
  • Putting selected on an <option>. In React the value goes on the <select>. React warns you if you do this.
  • Writing the textarea's content between the tags. In React it goes in value.
  • Forgetting preventDefault() in onSubmit. The page reloads and all the state is lost. The symptom is a flash and a blank form.
  • Putting the submit handler on the button instead of the <form>. Submitting with the Enter key stops working.
  • Forgetting type="button" on the form's auxiliary buttons. Without it, they default to submit and will submit the form when clicked.
  • Having a field's name not match the state's key. The generic handler will silently create a new key, and the field won't respond.
  • Forgetting the parentheses in (prev) => ({ … }). Without them the arrow function returns undefined and the state breaks.
  • Storing numbers as strings. '3' + 1 is '31'. Convert with Number() in the handler, handling the empty string separately.
  • Using event.target.reset() on a controlled form. It clears the DOM but not the state; React reverts it on the next render.
  • Tip: define the initial values in a constant outside the component. It serves both the useState and the reset, with no duplication.
  • Tip: don't store in state anything you can compute. The total, the chosen bike, and the validity are all derived values.
  • Tip: always put an id on the field and htmlFor on the label. It costs nothing and fixes usability and accessibility in one stroke.

Exercises

Exercise 1

This form has five bugs. Identify them, explain the symptom of each one, and write the corrected version.

function StationForm() {
  const [name, setName] = useState();
  const [district, setDistrict] = useState('Downtown');
  const [active, setActive] = useState(false);

  function handleSubmit() {
    console.log({ name, district, active });
  }

  return (
    <form>
      <input type="text" value={name} />

      <select>
        <option value="Downtown" selected>Downtown</option>
        <option value="North">North</option>
      </select>

      <input
        type="checkbox"
        checked={active}
        onChange={(e) => setActive(e.target.value)}
      />

      <button type="submit" onClick={handleSubmit}>Save</button>
    </form>
  );
}

Exercise 2

Create the CatalogueFilters component (src/components/CatalogueFilters.jsx), a controlled filter panel with a single state object and a generic handler. It must include:

  • A search text field to filter by model.
  • A type select with the options "todos", "urbana", "electrica" and "carga".
  • A status radio group with "todos", "disponible", "alquilada" and "mantenimiento".
  • An onlyWithDocks checkbox (boolean).
  • A maxPrice field of type number between 0 and 10, with a step of 0.5.
  • A "Clear filters" button that resets the initial values without submitting the form.

The component must notify the parent with onFilter(data) on every change.

Exercise 3

Extend the BookingForm from section 12 with two new fields, keeping the same single state:

  1. A returnStation select listing the three stations from domain.js, with the empty option "Same as pickup".
  2. A notes textarea capped at 300 characters, with a remaining-characters counter.

Then make the Booking object handed to the parent include both fields, and explain why the character counter must not be a separate useState.

Solutions

Solution 1.

The five bugs:

Bug Symptom
useState() with no initial value value={undefined} makes the field start out uncontrolled; typing makes React warn about the switch to controlled
The <input type="text"> has value but no onChange The field won't accept typing, and React warns it will be read-only
selected on the <option> In React the value goes on the <select>; it's also missing onChange, so the dropdown doesn't respond
setActive(e.target.value) on a checkbox value is "on", always truthy: the box can never be unchecked
onClick on the button instead of onSubmit on the <form>, with no preventDefault Submitting with Enter doesn't work, and clicking the button reloads the page, losing everything
// src/components/StationForm.jsx
import { useState } from 'react';

const DISTRICTS = ['Downtown', 'North', 'Riverside'];

/**
 * Form to register a CicloUrbano station.
 * Props:
 *  - onSave (function, optional): receives { name, district, active }
 */
function StationForm({ onSave }) {
  const [name, setName] = useState('');
  const [district, setDistrict] = useState('Downtown');
  const [active, setActive] = useState(false);

  function handleSubmit(event) {
    event.preventDefault();
    if (onSave) {
      onSave({ name, district, active });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Station name</label>
      <input
        id="name"
        type="text"
        value={name}
        onChange={(event) => setName(event.target.value)}
      />

      <label htmlFor="district">District</label>
      <select
        id="district"
        value={district}
        onChange={(event) => setDistrict(event.target.value)}
      >
        {DISTRICTS.map((districtName) => (
          <option key={districtName} value={districtName}>
            {districtName}
          </option>
        ))}
      </select>

      <label>
        <input
          type="checkbox"
          checked={active}
          onChange={(event) => setActive(event.target.checked)}
        />{' '}
        Station in service
      </label>

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

export default StationForm;

Solution 2.

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

const INITIAL_FILTERS = {
  search: '',
  type: 'todos',
  status: 'todos',
  onlyWithDocks: false,
  maxPrice: 10
};

const TYPES = ['todos', 'urbana', 'electrica', 'carga'];
const STATUSES = ['todos', 'disponible', 'alquilada', 'mantenimiento'];

/**
 * Filter panel for CicloUrbano's catalogue.
 * Props:
 *  - onFilter (function, optional): receives the filters object on every change
 */
function CatalogueFilters({ onFilter }) {
  const [filters, setFilters] = useState(INITIAL_FILTERS);

  function apply(next) {
    setFilters(next);
    if (onFilter) {
      onFilter(next);
    }
  }

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

    apply({ ...filters, [name]: finalValue });
  }

  function handleClear() {
    apply(INITIAL_FILTERS);
  }

  return (
    <form className="catalogue-filters" onSubmit={(event) => event.preventDefault()}>
      <div>
        <label htmlFor="search">Search model</label>
        <input
          id="search"
          name="search"
          type="text"
          value={filters.search}
          onChange={handleChange}
        />
      </div>

      <div>
        <label htmlFor="type">Type</label>
        <select id="type" name="type" value={filters.type} onChange={handleChange}>
          {TYPES.map((type) => (
            <option key={type} value={type}>
              {type}
            </option>
          ))}
        </select>
      </div>

      <fieldset>
        <legend>Status</legend>
        {STATUSES.map((status) => (
          <label key={status}>
            <input
              type="radio"
              name="status"
              value={status}
              checked={filters.status === status}
              onChange={handleChange}
            />{' '}
            {status}
          </label>
        ))}
      </fieldset>

      <label>
        <input
          name="onlyWithDocks"
          type="checkbox"
          checked={filters.onlyWithDocks}
          onChange={handleChange}
        />{' '}
        Only stations with free docks
      </label>

      <div>
        <label htmlFor="maxPrice">Maximum price per hour</label>
        <input
          id="maxPrice"
          name="maxPrice"
          type="number"
          min={0}
          max={10}
          step={0.5}
          value={filters.maxPrice}
          onChange={handleChange}
        />
      </div>

      {/* type="button" is essential: without it, it would submit the form */}
      <button type="button" onClick={handleClear}>
        Clear filters
      </button>
    </form>
  );
}

export default CatalogueFilters;

Details: the radios share name="status", so the generic handler treats them like a regular text field — the value comes from value, not checked; the clear button carries type="button"; and the form's onSubmit only calls preventDefault() so pressing Enter in the search box doesn't reload the page.

Solution 3.

The changes to BookingForm:

const MAX_NOTES_LENGTH = 300;

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

// New prop: stations
function BookingForm({ bikes = [], stations = [], userId = 'usr-01', onCreateBooking }) {
  const [formData, setFormData] = useState(INITIAL_DATA);

  // DERIVED value: recomputes on its own, can never fall out of sync
  const remaining = MAX_NOTES_LENGTH - formData.notes.length;

  // … handleChange stays unchanged: name and the spread already cover the new fields …
      <div className={styles.field}>
        <label htmlFor="returnStation">Return station</label>
        <select
          id="returnStation"
          name="returnStation"
          value={formData.returnStation}
          onChange={handleChange}
        >
          <option value="">Same as pickup</option>
          {stations.map((station) => (
            <option key={station.id} value={station.id}>
              {station.name} ({station.district})
            </option>
          ))}
        </select>
      </div>

      <div className={styles.field}>
        <label htmlFor="notes">Notes</label>
        <textarea
          id="notes"
          name="notes"
          rows={3}
          maxLength={MAX_NOTES_LENGTH}
          value={formData.notes}
          onChange={handleChange}
        />
        <small>{remaining} characters left.</small>
      </div>

And the booking object:

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

Why the counter must not be a separate useState: because remaining can always be computed from formData.notes.length. Storing it in state would create a second source of truth that you'd have to remember to update on every change, on the form's reset, and on any future edit to the text. If anyone forgot one of those updates, the counter would show a false number with nothing visibly failing. It's the rule from lesson 02-04: if it can be derived, it isn't state. Adding the new field to handleChange didn't take a single line, precisely because the handler is generic.

Conclusion

Forms are the point where the app stops just showing information and starts collecting it, and React solves the double-source-of-truth problem with a single pattern: the controlled component. You now know it's built from two inseparable pieces — value, which imposes the state, and onChange, which reports an attempt to change it — and why a lone value leaves the field frozen with a very explicit console warning. You know the quirks of every field type: the textarea that carries its content in value instead of between the tags; the select whose value goes on the element rather than the option, with its multiple variant that works with arrays; checkboxes that use checked instead of value; radio groups that share name and get checked by comparing against the state; and number and date fields, which always hand back strings and demand an explicit conversion that respects the empty-field case.

On top of that you've learned the pattern that makes a real form manageable: a single state object, a single generic handler, and the combination of name with a computed property name — { ...previous, [name]: value } — that updates only the touched field without mutating anything. And correct submission: onSubmit on the <form> so it also works with Enter, preventDefault() so the page doesn't reload, and a reset that's simply returning the state to its initial constant.

CicloUrbano finally has a complete BookingForm that builds a Booking object with the exact shape of domain.js's and hands it to the parent, showing along the way the total calculated from the chosen bike's price. But it accepts anything: you can submit it with no bike chosen, with a date from last year, with zero hours, or without accepting the terms. A form that doesn't validate isn't finished. In Form Validation and Uncontrolled Components you'll see the other half of the story: when to validate and how to do it without shouting at someone who's still typing, what the browser's native validation offers and what it doesn't, and when it's worth giving up control and letting the DOM hold the value instead.

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