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
- The conflict: two sources of truth
- What a controlled component is
value+onChangestep by step- A
valuewith noonChange: the frozen field - Text fields and
textarea - The
selectdropdown, single and multiple - Checkboxes with
checked - Radio button groups
numberanddate: type conversion- A single state for the whole form
- Submission:
onSubmitandpreventDefault - CicloUrbano:
BookingForm
- 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.
- 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".
value + onChange step by step
value + onChange step by stepThe two ingredients are inseparable. Let's break them down one at a time.
value: the state is in charge
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
Three important details about this line:
event.targetis the DOM node that triggered the event: the<input>. As you learned in 03-01, it's different fromcurrentTarget, though on a standalone field they coincide..valueis always a text string, even on an<input type="number">. We'll come back to this in section 9.- In React,
onChangefires on every keystroke, unlike the DOM's nativechangeevent, 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.
- A
value with no onChange: the frozen field
value with no onChange: the frozen fieldThis 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.
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
valueprop to a form field without anonChangehandler. This will render a read-only field. If the field should be mutable usedefaultValue. Otherwise, set eitheronChangeorreadOnly.
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
- Text fields and
textarea
textareaSingle-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:
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;
- The
select dropdown, single and multiple
select dropdown, single and multipleHere 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>'svaluemust match thevalueof 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
mapneed akey, like any list (lesson 03-03). event.target.valueis always a string. If your identifiers were numeric, you'd need conversions; with CicloUrbano'sest-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.
- Checkboxes with
checked
checkedA 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.
- 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."
number and date: type conversion
number and date: type conversionHere'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 luckThe 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.
- 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
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
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 |
- Submission:
onSubmit and preventDefault
onSubmit and preventDefaultThe 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:
- Pressing the
type="submit"button. - Pressing
Enterin any text field of the form. - 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_DATAmust never be mutated. SincesetFormDataalways 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.
- CicloUrbano:
BookingForm
BookingFormIt'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_DATAwithhours: 2reproduces the canonical value from bookingres-01indomain.js, so the form suggests the usual duration.- A single state and a single handler for four fields of three different types, thanks to
nameand the computed property name. - The per-type conversion is centralized in
handleChange:checkedfor the checkbox,Numberfor the numeric field (respecting the empty string), andvalueas-is for the rest. available,chosenBike,totalandformattedTotalare derived values. There isn't a single extrauseState: 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. htmlForon every<label>points at the field'sid. 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
valuewith noonChange. The field becomes read-only and React warns about it. Add the handler, orreadOnlyif it's intentional.- Initializing state with
undefinedornull. It triggers the "changing an uncontrolled input to be controlled" warning. Use''for text andfalsefor checkboxes. - Reading
event.target.valueon a checkbox. It returns"on", which is always truthy: the box can never be unchecked. Useevent.target.checked. - Putting
selectedon 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 invalue. - Forgetting
preventDefault()inonSubmit. 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 theEnterkey stops working. - Forgetting
type="button"on the form's auxiliary buttons. Without it, they default tosubmitand will submit the form when clicked. - Having a field's
namenot 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 returnsundefinedand the state breaks. - Storing numbers as strings.
'3' + 1is'31'. Convert withNumber()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
useStateand 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
idon the field andhtmlForon 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
searchtext field to filter by model. - A
typeselectwith the options "todos", "urbana", "electrica" and "carga". - A
statusradio group with "todos", "disponible", "alquilada" and "mantenimiento". - An
onlyWithDockscheckbox (boolean). - A
maxPricefield of typenumberbetween 0 and 10, with astepof 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:
- A
returnStationselectlisting the three stations fromdomain.js, with the empty option "Same as pickup". - A
notestextareacapped 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
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
