The previous module finished by laying out the map of hooks; now the journey across the territory itself begins, and the first stop on that map is one you've already been using since 02-04: useState. There you learned the essentials — declaring state, updating it, never mutating it — and that was enough to make the CicloUrbano catalogue work. But useState hides a behavior that surprises almost everyone the first time they run into it: during a single render, the state variable never changes, no matter how many times you call the updater. Anyone who misses this ends up writing code that "loses" updates for no apparent reason, and starts to distrust React. In this lesson you'll understand the real mechanism: state as a snapshot, the update queue, the batching of changes, lazy initialization, a complete recipe book for immutable updates applied to CicloUrbano's bookings, five principles for structuring state well, and the trick of resetting it with the key prop.
Contents
- The signature of
useStateand the pair it returns - Why the updater function is stable across renders
- State is a snapshot of the render
- Three
setCounter(counter + 1)calls that add just one - The functional form and the update queue
- Batching updates
- Lazy initialization and the direct-calculation trap
- Objects and arrays: a recipe book for immutable updates
- Structuring state well: five principles
- Resetting a component's state with the
keyprop - Direct value or updater function: a decision table
- The signature of
useState and the pair it returns
useState and the pair it returnsuseState takes one argument — the initial value — and always returns an array with exactly two entries:
| Position | What it is | How it's used |
|---|---|---|
[0] |
The state value for this render | Read it. Never assign to it |
[1] |
The updater function | Call it to request a change and a new render |
Returning an array instead of an object isn't a whim: destructuring an array lets you pick the names yourself, position by position. If it returned { value, setValue } you'd have to rename on every call, and with three pieces of state in the same component the code would become unreadable.
// With an array (what React does): free naming, one line
const [chosenType, setChosenType] = useState('todos');
const [bookingHours, setBookingHours] = useState(2);
// If it returned an object: mandatory renaming on every use
const { value: chosenType, set: setChosenType } = useState('todos');The universal convention across the ecosystem is [something, setSomething], and this lesson follows it throughout.
The argument is only used on the first render. From then on React ignores whatever you pass: the initial value is exactly that, initial. This line doesn't "reset" anything on render number 40:
- Why the updater function is stable across renders
This is a property that gets stated far too rarely, and it turns out to be very useful:
React guarantees that the updater function returned by
useStateis the same reference across every render of the component. It is never recreated.
Verify it with an identity comparison:
let previousUpdater = null;
function DockCounter({ station }) {
const [occupied, setOccupied] = useState(0);
console.log('Same updater as the previous render?', setOccupied === previousUpdater);
previousUpdater = setOccupied;
// render 1: false (there was no previous one) · render 2, 3, 4...: always trueThree practical consequences you'll rely on in the coming lessons:
- You don't need to include it in an effect's dependencies (05-02). The linter knows this and won't ask you to.
- You don't need to wrap it in
useCallback(08-03) to pass it to a memoized child: it's already stable by definition. - You can pass it straight down as a callback prop with no fear of triggering unnecessary renders.
// Correct and sufficient: setOccupied never changes
useEffect(() => {
const id = setInterval(() => setOccupied((previous) => previous + 1), 5000);
return () => clearInterval(id);
}, []); // no setOccupied in the dependencies: React guarantees it's stableThe same guarantee applies to the dispatch function returned by useReducer, as you'll see in 05-05.
- State is a snapshot of the render
This is the central idea of the lesson, and it's worth stating slowly.
Every render is a frozen photograph. The state variables of that render are constants: they hold whatever value they had when React called the component function, and they don't change until the next render begins.
Recall how a function component works: it's a function that React calls. On every render, React executes it again, and useState returns the value that belongs to that specific render. The bookingHours variable isn't a magic box wired up to React — it's a local const bound to that one execution.
function BookingPanel({ bike, hours, onHoursChange }) {
// 'hours' is a constant FOR THIS RENDER. Nothing will change it during this execution.
console.log('Rendering with hours =', hours);
...
}The example that makes this obvious is a handler with a delay:
function HoursSelector() {
const [hours, setHours] = useState(2);
function handleDelayedBooking() {
setHours(hours + 3); // request a move to 5
setTimeout(() => {
alert(`Booking for ${hours} hours`); // what does it show: 2 or 5?
}, 3000);
}
return (
<>
<p>Hours: {hours}</p>
<button type="button" onClick={handleDelayedBooking}>Book in 3 s</button>
</>
);
}It shows 2. And that isn't a bug — it's exactly correct. The handleDelayedBooking handler was created during the render where hours was 2, and that function captures that value. Even though three seconds later the screen already shows 5, the function still lives inside the old render's photograph. This is the normal behavior of JavaScript closures, applied to React.
Think of it this way: if you press "submit" on a form and then, while it's submitting, you change a field, the message that gets sent must be the one that was there when you pressed submit, not the one there now. The snapshot isn't an obstacle — it's what makes events predictable.
- Three
setCounter(counter + 1) calls that add just one
setCounter(counter + 1) calls that add just oneThis is the classic example, and now you have the tools to explain it. Let's add a button to CicloUrbano that frees up three docks at once:
// src/components/DockCounter.jsx (buggy version)
import { useState } from 'react';
function DockCounter() {
const [freeDocks, setFreeDocks] = useState(0);
function handleReleaseThree() {
setFreeDocks(freeDocks + 1);
setFreeDocks(freeDocks + 1);
setFreeDocks(freeDocks + 1);
}
return (
<>
<p>Free docks: {freeDocks}</p>
<button type="button" onClick={handleReleaseThree}>Release 3 docks</button>
</>
);
}You click once and the counter shows 1, not 3. Mentally substitute the variable with its value for that render and you'll see why:
// During that render, freeDocks is 0. The three lines are, literally:
setFreeDocks(0 + 1); // queues: "the next state is 1"
setFreeDocks(0 + 1); // queues: "the next state is 1"
setFreeDocks(0 + 1); // queues: "the next state is 1"All three calls queue the exact same instruction: "set the state to 1". React processes the queue, gets 1, and renders once. No update got lost — the three of them were just saying the same thing.
The conclusion to internalize: calling the updater does not change the variable in the current execution. setFreeDocks(1) doesn't do freeDocks = 1; it does "note that on the next render it'll be 1".
- The functional form and the update queue
The fix is to pass an updater function instead of a value. React stores it in the queue and runs each one in turn, feeding it the previous result.
function handleReleaseThree() {
setFreeDocks((previous) => previous + 1);
setFreeDocks((previous) => previous + 1);
setFreeDocks((previous) => previous + 1);
}Now it's 3. Here's how React processes the queue before the next render:
flowchart LR
A["Current state: 0"] --> B["f1: previous => previous + 1<br/>0 → 1"]
B --> C["f2: previous => previous + 1<br/>1 → 2"]
C --> D["f3: previous => previous + 1<br/>2 → 3"]
D --> E["State for the next render: 3"]
style A fill:#e0f2fe
style E fill:#dcfce7
And here's the contrast with the direct-value version, where every entry in the queue is a fixed value that replaces whatever came before:
| Queued entries | How React processes them | Final state |
|---|---|---|
1, 1, 1 |
Replace, replace, replace | 1 |
f, f, f with f = p => p + 1 |
f(0)=1, f(1)=2, f(2)=3 |
3 |
5, f, f |
Replace with 5, f(5)=6, f(6)=7 |
7 |
The third row shows that the two can be mixed: a direct value discards whatever came before and restarts the computation. That's useful, for instance, for "reset and add":
setFreeDocks(0); // reset
setFreeDocks((previous) => previous + station.docks); // add on top of the freshly set 0Rules for writing good updater functions:
- They must be pure: compute and return the new state, with no
console.logthat depends on ordering, no requests, no mutating anything. React may call them twice in development underStrictMode(01-03) precisely to catch impurities. - Name the parameter clearly:
previous,previousBookings,previousData. Avoid a bare single letter. - Don't read other state variables inside them: if the computation depends on two pieces of state at once, that's a sign those two should be a single one, or that it's time for
useReducer(05-05).
- Batching updates
React doesn't repaint after every single call to the updater: it batches all the updates that happen and does a single render at the end. This is called batching.
function handleConfirm({ bike, hours }) {
setSelectedBike(null); // 1
setBookingHours(2); // 2
setNoticeVisible(true); // 3
// React has NOT rendered yet. It renders ONCE when this handler finishes.
}Three state changes, a single render. Without batching you'd get three renders and intermediate states visible on screen: the interface would briefly show the empty panel with the old hours still in it. Batching avoids those half-finished states.
The important change from React 18 onward: batching is now automatic everywhere. Before, React only batched inside its own event handlers; code inside promises, timers, or native handlers triggered one render per call.
| Context | React 17 | React 18 and 19 |
|---|---|---|
React onClick handler |
Batches (1 render) | Batches (1 render) |
Inside setTimeout |
Doesn't batch (3 renders) | Batches (1 render) |
Inside a promise's .then() |
Doesn't batch (3 renders) | Batches (1 render) |
Inside a native addEventListener |
Doesn't batch (3 renders) | Batches (1 render) |
// In React 19 this triggers a SINGLE render, not three
async function handleLoadStations() {
const response = await fetch('/api/estaciones');
const data = await response.json();
setStations(data); // ┐
setLoading(false); // ├─ a single render at the end
setError(null); // ┘
}If you ever need to force an immediate repaint before continuing — a very rare case, typically to measure the DOM — react-dom provides flushSync. Treat it as an escape hatch: needing it regularly is usually a sign something is designed wrong.
- Lazy initialization and the direct-calculation trap
useState's argument is only used on the first render, but it gets evaluated on every one. That difference has consequences.
// ❌ BAD: calculateFleetSummary(bikes) RUNS on every render
const [summary, setSummary] = useState(calculateFleetSummary(bikes));Even though React discards the result from the second render onward, JavaScript still has to run the function in order to pass its value as an argument. If it loops over five bikes, no big deal; if it reads localStorage, parses a large JSON payload, or loops over thousands of bookings, you're paying that cost on every repaint.
The fix is to pass the function itself, without calling it. React will run it exactly once, during initialization:
// ✅ GOOD: React calls the function only on the first render
const [summary, setSummary] = useState(() => calculateFleetSummary(bikes));A very common case in CicloUrbano: recovering the booking draft saved in the browser.
function BookingForm({ onCreateBooking }) {
const [data, setData] = useState(() => {
const saved = localStorage.getItem('ciclourbano:borrador');
return saved ? JSON.parse(saved) : INITIAL_DATA;
});
...
}Without the wrapping function, every keystroke in the form would trigger a render, and every render would read and parse localStorage only to throw the result away.
| Form | When the computation runs | When to use it |
|---|---|---|
useState(value) |
Never any computation: it's a literal | Simple values: 0, '', false, 'todos', null |
useState(compute()) |
On every render | Never, unless compute is trivial |
useState(() => compute()) |
Only on the first render | Expensive computations, localStorage reads, JSON parsing |
Watch out for the ambiguity when the state itself is a function. If you want to store a function as a state value, useState(myFunction) would interpret it as a lazy initializer and store its result instead. You need to wrap it: useState(() => myFunction).
- Objects and arrays: a recipe book for immutable updates
02-04 established the rule: state gets replaced, not modified. React compares the old reference against the new one and, if they're the same, considers nothing changed and skips the render. Here's the full recipe book, applied to CicloUrbano's booking list.
We start from this state in App:
// src/data/domain.js
export const bookings = [
{
id: 'res-01',
bicicletaId: 'bici-002',
user: 'usr-01',
startDate: '2026-05-04T09:00',
hours: 2,
status: 'activa'
}
];Array recipes
| Operation | ❌ Mutates (bad) | ✅ Replaces (good) |
|---|---|---|
| Add at the end | arr.push(x) |
setArr([...arr, x]) |
| Add at the start | arr.unshift(x) |
setArr([x, ...arr]) |
| Remove by id | arr.splice(i, 1) |
setArr(arr.filter((e) => e.id !== id)) |
| Replace an item | arr[i] = x |
setArr(arr.map((e) => (e.id === id ? x : e))) |
| Sort | arr.sort(f) |
setArr([...arr].sort(f)) |
| Reverse | arr.reverse() |
setArr([...arr].reverse()) |
| Insert at a position | arr.splice(i, 0, x) |
setArr([...arr.slice(0, i), x, ...arr.slice(i)]) |
| Empty it out | arr.length = 0 |
setArr([]) |
The four cases that come up most, written with the functional form (because they depend on the previous value):
// ADD a new booking
function handleCreateBooking(newBooking) {
setBookingList((previous) => [...previous, newBooking]);
}
// REMOVE (permanently cancel) by identifier
function handleDeleteBooking(bookingId) {
setBookingList((previous) => previous.filter((booking) => booking.id !== bookingId));
}
// REPLACE an entire booking
function handleReplaceBooking(updatedBooking) {
setBookingList((previous) =>
previous.map((booking) => (booking.id === updatedBooking.id ? updatedBooking : booking))
);
}
// CHANGE ONE PROPERTY of an item (without touching the rest)
function handleCancelBooking(bookingId) {
setBookingList((previous) =>
previous.map((booking) =>
booking.id === bookingId ? { ...booking, status: 'cancelada' } : booking
)
);
}Look closely at handleCancelBooking: the map returns the same object for bookings that don't change and a new object only for the one affected. That's exactly what we want: the smallest possible number of new references, so the optimizations in Module 8 can skip over everything else.
A note on sort and reverse: they're methods that mutate the original array. [...arr].sort(f) copies it first. Modern JavaScript also offers toSorted() and toReversed(), which already return a copy and make the [...arr] unnecessary.
Object and nesting recipes
const [draft, setDraft] = useState({
bicicletaId: '',
startDate: '',
hours: 2,
terms: false,
contact: { email: '', phone: '' } // ← nested level
});| Operation | ✅ How to do it |
|---|---|
| Change a property | setDraft({ ...draft, hours: 4 }) |
| Change a dynamic property | setDraft({ ...draft, [field]: value }) |
| Change a nested property | setDraft({ ...draft, contact: { ...draft.contact, email } }) |
| Remove a property | const { hours, ...rest } = draft; setDraft(rest); |
The nested case is the one that causes the most errors, because you have to copy every level along the path down to the property that changes:
function handleEmailChange(email) {
setDraft((previous) => ({
...previous, // copy level 1
contact: { ...previous.contact, email } // copy level 2 and change email
}));
}This forgets to copy the intermediate level and wipes out the phone number:
// ❌ contact becomes { email } and the phone number disappears
setDraft((previous) => ({ ...previous, contact: { email } }));And this one looks like it works but is a disguised mutation: it modifies the object that was already in state, so draft's reference doesn't change and React may not re-render:
// ❌ mutation: previous.contact is the SAME object that's already in state
setDraft((previous) => {
previous.contact.email = email;
return { ...previous };
});Also keep in mind that the parentheses around the object in (previous) => ({ ... }) are mandatory: without them, JavaScript interprets the braces as the function body, and the arrow function returns undefined.
If the nesting goes past two levels, the right answer is almost never longer spreads — it's flattening the state (next section) or reaching for an immutability library like Immer, which lets you write code that looks mutable while producing copies underneath.
- Structuring state well: five principles
Choosing what to store in state matters more than knowing how to update it. These five principles head off most of the state bugs you'll see in production.
- Group what changes together
If two variables always update together, they're really one.
// ❌ two pieces of state that never change independently
const [x, setX] = useState(0);
const [y, setY] = useState(0);
// ✅ just one
const [position, setPosition] = useState({ x: 0, y: 0 });
- Don't duplicate the same data
This is the single source of truth rule from 04-01, now applied inside a single component.
// ❌ the bike exists twice: if the list changes, the copy goes stale
const [bikes, setBikes] = useState(initialData);
const [selectedBike, setSelectedBike] = useState(null);
// ✅ store the identifier and look up the object during render
const [selectedId, setSelectedId] = useState(null);
const selectedBike = bikes.find((b) => b.id === selectedId) ?? null;The correct version has an immediate benefit: if an operator changes bici-002's status to "maintenance", the selected card finds out. With the copy, it would keep showing stale data.
- Avoid contradictory state
Three loose booleans allow combinations that shouldn't exist.
// ❌ what does loading=true and error='Failed' mean? It's an impossible state
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);With that structure there are 2 × 2 × 2 = 8 combinations and only 4 make sense. Forgetting a single setLoading(false) in an error branch is enough to make the interface show the failure message and the loading indicator at the same time.
// ✅ a single variable with the states that actually EXIST
const [loadState, setLoadState] = useState('idle');
// 'idle' | 'loading' | 'success' | 'error'
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const isLoading = loadState === 'loading'; // derived, not state
- Avoid redundant state that you can derive
If a value can be computed from others, it isn't state. You already saw this in 04-01 with visibleBikes; the most common mistake is a computed "total":
// ❌ redundant: you have to remember to recompute it on every change
const [hours, setHours] = useState(2);
const [total, setTotal] = useState(5);
// ✅ derived: always correct, impossible to get out of sync
const [hours, setHours] = useState(2);
const total = hours * bike.pricePerHour;
- Flatten what's nested
Storing the hierarchy exactly as it arrives from the server forces three-level spreads just to change one value. Storing indexes by identifier turns that operation into a single line.
// ❌ deeply nested: changing one bike means copying the station and the list
const [network, setNetwork] = useState({
stations: [{ id: 'est-01', name: 'Main Square', bikes: [{ id: 'bici-001', ... }] }]
});
// ✅ flattened by identifier
const [stationsById, setStationsById] = useState({
'est-01': { id: 'est-01', name: 'Main Square', district: 'Downtown', docks: 20, bikeIds: ['bici-001', 'bici-002'] }
});
const [bikesById, setBikesById] = useState({
'bici-001': { id: 'bici-001', model: 'Classic Urban', type: 'urbana', status: 'disponible', stationId: 'est-01', pricePerHour: 2.5 }
});
// Change one bike's status: a single line
setBikesById((previous) => ({
...previous,
'bici-001': { ...previous['bici-001'], status: 'mantenimiento' }
}));When these five principles aren't enough — many related pieces, transitions governed by rules, handlers that touch three pieces of state at once — the answer is no longer reorganizing useState; it's switching tools. useReducer gathers all those pieces under a single transition function. That's lesson 05-05.
- Resetting a component's state with the
key prop
key propReact keeps a component's state alive as long as it renders in the same position of the tree with the same type. This produces a behavior that catches people off guard: if you switch the selected bike, BookingPanel keeps whatever hours were set for the previous one.
<BookingPanel bike={selectedBike} />
// Switches from bici-001 to bici-005 → same component, same position → internal state survivesYou could "fix" this with an effect that resets the state whenever the prop changes, but that's the worst possible solution, and we'll cover it as an antipattern in 05-02. The idiomatic fix is a different key:
<BookingPanel
key={selectedBike?.id}
bike={selectedBike}
hours={bookingHours}
onHoursChange={handleHoursChange}
onConfirm={handleConfirm}
/>When the key changes, React treats it as an entirely different component: it unmounts the previous one along with all its state and mounts a brand-new one from scratch. It's the same key you use in lists (03-03), applied here for a different purpose: not identifying siblings, but declaring identity over time.
flowchart TD
A["selectedBike = bici-001"] --> B["BookingPanel key='bici-001'<br/>hours: 4"]
B --> C{"User selects bici-005"}
C --> D["React sees a different key"]
D --> E["Unmounts the previous panel<br/>(its state is lost)"]
E --> F["Mounts BookingPanel key='bici-005'<br/>hours: 1 (initial value)"]
style E fill:#fecaca
style F fill:#dcfce7
Typical cases where key is the right tool:
- A form that must clear itself when the edited item changes.
- A detail panel that must return to its initial tab when the record changes.
- A third-party component that offers no way to reset itself.
- Direct value or updater function: a decision table
| Situation | Recommended form | Example |
|---|---|---|
| The new value doesn't depend on the previous one | Direct value | setChosenType('electrica') |
| The new value does depend on the previous one | Updater function | setHours((previous) => previous + 1) |
| Several updates to the same state in a row | Updater function, always | setDocks((p) => p + 1) × 3 |
Inside a setTimeout, fetch, or await |
Updater function | setCounter((p) => p + 1) after 5 s |
| Inside an effect with a reduced dependency list | Updater function | avoids adding the state to [deps] (05-02) |
| Adding, removing, or modifying inside an array/object | Updater function | setBookings((previous) => [...previous, newOne]) |
| Setting a fixed value or resetting | Direct value | setSelectedBike(null) |
Practical rule to avoid second-guessing yourself: use the functional form whenever the new state is computed from the current one. It costs nothing in performance, doesn't complicate the code, and eliminates an entire family of hard-to-reproduce bugs at the root.
Common Mistakes and Tips
- Reading state right after updating it.
setHours(5); console.log(hours);prints the old value. That's the snapshot from section 3, not a delay. If you need the new value, compute it into a constant first:const newHours = 5; setHours(newHours); console.log(newHours);. - Calling the updater during render.
setCounter(1)in the component body triggers an infinite loop ("Too many re-renders"). Updaters get called from handlers or from effects, never during render. The one rare exception is conditionally adjusting state inside anifthat breaks the loop. useState(compute())instead ofuseState(() => compute()). The computation runs on every render. Double-check this line whenever the initializer isn't a literal.- Mutating state and repainting "by hand".
bookings.push(newBooking); setBookings(bookings);doesn't repaint: the reference is the same. And if memoization is also in play (Module 8), the bug becomes intermittent. - Boolean states that contradict each other.
loading,error, andsuccessas three independent booleans end up showing impossible screens. A single string with the valid states fixes it. - Storing the whole object in state when the id would do. This is the duplication from principle 2: the copy ages badly.
- Forgetting the parentheses in
(previous) => ({ ... }). Without them the function returns nothing and the state becomesundefined. - Debugging tip: when a piece of state isn't updating the way you expect, write out the line mentally substituting each variable with its value for that render. Most mysteries get solved right there.
- Design tip: before adding a
useState, ask yourself "can I compute this instead?" Most excess state is derived state that nobody bothered to derive.
Exercises
Exercise 1. This CicloUrbano component is supposed to add hours all at once depending on which button gets pressed, but clicking "+3 hours" only adds one. Fix it and explain why it was failing.
import { useState } from 'react';
function HoursAdjuster() {
const [hours, setHours] = useState(1);
function handleAddThree() {
setHours(hours + 1);
setHours(hours + 1);
setHours(hours + 1);
}
function handleResetAndAdd() {
setHours(0);
setHours(hours + 2);
}
return (
<>
<p>Booking hours: {hours}</p>
<button type="button" onClick={handleAddThree}>+3 hours</button>
<button type="button" onClick={handleResetAndAdd}>Reset and +2</button>
</>
);
}Exercise 2. CicloUrbano's booking panel stores its state like this. Restructure it applying the five principles from section 9, and write the handlers handleSelect, handleHoursChange, and handleConfirm against the new structure.
const [bookings, setBookings] = useState(initialBookings);
const [selectedBooking, setSelectedBooking] = useState(null); // whole object
const [hours, setHours] = useState(2);
const [totalPrice, setTotalPrice] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');Exercise 3. Write the three handlers that manage App's booking list, all with immutable, functional-form updates: handleConfirm(bookingId) changes that booking's status to 'confirmada'; handleCancel(bookingId) changes it to 'cancelada' and adds a cancelledAt property with the current ISO date; handlePurge() removes every booking whose status is 'cancelada' from the list. Start from the state const [bookingList, setBookingList] = useState(bookings);.
Solutions
Solution 1.
function handleAddThree() {
setHours((previous) => previous + 1);
setHours((previous) => previous + 1);
setHours((previous) => previous + 1);
}
function handleResetAndAdd() {
setHours(0); // direct value: discards whatever came before
setHours((previous) => previous + 2); // functional: operates on the freshly queued 0
}handleAddThree was failing because hours is a constant for that render. With hours = 1, the three lines were literally setHours(2) three times over: the queue held 2, 2, 2, and the result was 2 (a single hour added). With the functional form, the queue holds three functions that React applies in a chain: 1→2→3→4.
In handleResetAndAdd the mix is intentional. With the original version (setHours(0); setHours(hours + 2);) and hours = 5, the queue would be 0 followed by 7: the reset gets lost and the result is 7. With the fixed version the queue is 0 followed by f, so f(0) = 2, which is exactly what the button promises.
Solution 2.
// Eight useState calls → three, with no impossible states and no duplicated data
const [bookingList, setBookingList] = useState(initialBookings);
const [selectedId, setSelectedId] = useState(null); // the ID, not the object (principle 2)
const [hours, setHours] = useState(2);
const [submission, setSubmission] = useState({ state: 'idle', error: null });
// state: 'idle' | 'submitting' | 'submitted' | 'error' (principle 3)
// DERIVED: not state (principle 4)
const selectedBooking = bookingList.find((b) => b.id === selectedId) ?? null;
const bike = selectedBooking
? bikes.find((b) => b.id === selectedBooking.bicicletaId)
: null;
const totalPrice = bike ? hours * bike.pricePerHour : 0;
const isSubmitting = submission.state === 'submitting';
function handleSelect(bookingId) {
setSelectedId(bookingId);
setSubmission({ state: 'idle', error: null });
}
function handleHoursChange(newHours) {
setHours(Math.max(1, newHours));
}
async function handleConfirm() {
setSubmission({ state: 'submitting', error: null });
try {
await submitBooking({ id: selectedId, hours, total: totalPrice });
setBookingList((previous) =>
previous.map((booking) =>
booking.id === selectedId ? { ...booking, hours, status: 'confirmada' } : booking
)
);
setSubmission({ state: 'submitted', error: null });
} catch (error) {
setSubmission({ state: 'error', error: error.message });
}
}The changes, principle by principle: selectedBooking stops duplicating the object and gets derived from the id instead (2); totalPrice is computed on every render instead of being stored (4); and the four submission booleans collapse into a single variable with four possible values, so the "submitting and showing an error at the same time" combination no longer exists (3). On top of that, submission groups a state label and its message, which always change together (1). With setSubmission({ state: 'error', error: error.message }) it's impossible to forget to turn off the loading indicator.
Solution 3.
const [bookingList, setBookingList] = useState(bookings);
function handleConfirm(bookingId) {
setBookingList((previous) =>
previous.map((booking) =>
booking.id === bookingId ? { ...booking, status: 'confirmada' } : booking
)
);
}
function handleCancel(bookingId) {
setBookingList((previous) =>
previous.map((booking) =>
booking.id === bookingId
? { ...booking, status: 'cancelada', cancelledAt: new Date().toISOString() }
: booking
)
);
}
function handlePurge() {
setBookingList((previous) => previous.filter((booking) => booking.status !== 'cancelada'));
}Three details worth noting: the map returns the same reference for bookings that aren't affected, so only the object that actually changed gets a new one; new Date().toISOString() runs inside the handler, not during render, respecting purity (04-03); and filter returns a brand-new array by definition, so there's no need to copy it beforehand.
Conclusion
useState is a lot more than it looked like back in 02-04. Now you know it returns a [value, updater] pair where the updater is stable forever, that the value is a frozen snapshot of the current render, and that's exactly why three setCounter(counter + 1) calls in a row only add one. The functional form solves that case because it queues functions that React applies in a chain; batching — automatic everywhere since React 18 — turns several changes into a single render; and lazy initialization avoids repeating expensive computations on every repaint. You also have the full recipe book for immutable updates on arrays and objects, applied to CicloUrbano's bookings, five principles for structuring state with no duplication, contradictions, or redundancy, and the key prop trick for resetting an entire component whenever what it represents changes.
With this you can manage any data that lives inside React. But a real application doesn't live in isolation: there are timers marking station availability, browser events, a tab title to keep updated and, above all, a server to fetch bikes from. All of that lives outside React, and connecting to it has its own rules: when to start, when to stop, and how to avoid leaving a mess behind. It's the synchronizing with external systems model that got announced back in 04-03, and it's finally time to build it out. The next lesson is The useEffect Hook.
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
