Events, State, and Renders
Introduction
This is lesson 2 of chapter 5, aligned with course 5 of the Meta Front-End Developer pathway ("React Basics", domain react-basics). The previous lesson built components that display data they receive through props. Props are read-only, so a component that must remember something and change over time, such as a seat counter, an open menu or the text typed in a box, needs a different tool: state.
You will learn how to respond to user actions with event handlers, how useState stores data between renders, why React re-renders, and why updating state correctly (immutably, and sometimes with an updater function) matters. The practice scenario is original: a Harbor Workshops card that lets a visitor reserve and release seats while the interface stays consistent. By the end you should be able to predict exactly what a component will show after a sequence of clicks, which is the skill that React questions test most often.
Learning objectives
- Attach event handlers to elements and distinguish passing a function from calling it.
- Explain what state is and when a value should be state rather than a variable or a prop.
- Use
useStatewith primitive, object, and array values. - Describe the render cycle: trigger, render, commit.
- Explain why state updates are scheduled, and why a state variable is a snapshot within one render.
- Use updater functions when the next state depends on the previous one.
- Update objects and arrays immutably.
- Lift state up and derive values instead of duplicating them.
1. Events in React
Event handlers are functions you pass to props named in camelCase: onClick, onChange, onSubmit, onMouseEnter, onKeyDown.
function ReserveButton() {
function handleClick() {
alert('Seat requested');
}
return <button onClick={handleClick}>Reserve</button>;
}
The most common mistake is calling the function instead of passing it.
| Code | What happens |
|---|---|
onClick={handleClick} |
Passes the function. React calls it on click. Correct. |
onClick={handleClick()} |
Calls it during rendering and passes its return value (often undefined). Wrong. |
onClick={() => handleClick(7)} |
Passes a new arrow function that calls handleClick(7) on click. Correct, used to pass arguments. |
onClick={() => alert('x')} |
Inline handler. Fine for one short statement. |
Handlers receive an event object (a synthetic event that wraps the browser event). Two methods you will use constantly:
event.preventDefault()stops the browser default, for example a form reload on submit.event.stopPropagation()stops the event from bubbling to parent elements.
Naming convention: define handlers as handleSomething and pass them to props named onSomething. When a component accepts a callback from its parent, name the prop onSomething too, for example <ReserveButton onReserve={reserveSeat} />.
Handlers are the right place for side effects caused by a user action (changing state, sending a request). They are not run during rendering, which is why they are allowed to change things that rendering must not.
2. Why regular variables are not enough
Consider this attempt to build a seat counter.
function SeatCounter() {
let seats = 10;
function handleReserve() {
seats = seats - 1;
console.log(seats);
}
return (
<>
<p>Seats left: {seats}</p>
<button onClick={handleReserve}>Reserve</button>
</>
);
}
Click the button three times. The console prints 9, 8, 7, but the screen still says Seats left: 10. Two things are wrong:
- Local variables do not persist between renders. Each time React calls
SeatCounter,let seats = 10runs again from scratch. - Changing a local variable does not trigger a render. React has no way to know your variable changed.
State solves both problems: React stores the value outside your function so it survives, and calling the setter tells React to render again.
3. useState
import { useState } from 'react';
function SeatCounter() {
const [seats, setSeats] = useState(10);
function handleReserve() {
setSeats(seats - 1);
}
return (
<>
<p>Seats left: {seats}</p>
<button onClick={handleReserve}>Reserve</button>
</>
);
}
useState(10) returns an array of exactly two items: the current value (seats) and a setter function (setSeats). The argument 10 is the initial value, used only on the first render. The destructuring names are yours; the convention is [thing, setThing].
Rules of hooks (functions whose names start with use):
- Call hooks only at the top level of a component or a custom hook, never inside conditions, loops, or nested functions. React identifies each state by the order of the calls.
- Call hooks only from React function components or custom hooks, never from regular JavaScript functions.
Each component instance has its own state. Two <SeatCounter /> elements on a page do not share seats, and the same component rendered in two positions of the tree has two independent states.
Expensive initial values
If computing the initial value is costly, pass a function (a lazy initializer). React calls it only on the first render.
const [filters, setFilters] = useState(() => readFiltersFromUrl());
Passing useState(readFiltersFromUrl()) would call the function on every render and throw away the result after the first.
4. The render cycle: trigger, render, commit
React updates the screen in three phases. Understanding them explains nearly every state puzzle.
- Trigger. A render is triggered by the first mounting of the app (
root.render) or by a state update in a component (or an ancestor re-rendering). - Render. React calls your component function with the current props and state to compute the new JSX. Rendering must be pure: no mutation of outside variables, no network calls, no timers.
- Commit. React compares the new output with the previous one and changes only the DOM nodes that differ. If the output is identical, the DOM is not touched.
When a component's state changes, React re-renders that component and all its children (unless optimized, see chapter 6). Re-rendering is normally cheap and is not the same as re-creating DOM nodes.
State is a snapshot
Within a single render, the state variable has one fixed value, like a photo taken at the start of the render. The setter does not change the variable in the running function; it schedules a new render with a new value.
function handleReserve() {
setSeats(seats - 1);
setSeats(seats - 1);
setSeats(seats - 1);
console.log(seats); // still the old value in this handler
}
Suppose seats is 10. All three calls read the same snapshot, so each asks for 9, and the final result after the render is 9, not 7. The console.log also prints 10.
React also batches updates: all state changes in one event handler are processed together in one re-render, which avoids showing half-finished states.
5. Updater functions
When the next value depends on the previous state, pass a function to the setter. React queues it and runs the queue in order, feeding each result into the next.
function handleReserveThree() {
setSeats(s => s - 1);
setSeats(s => s - 1);
setSeats(s => s - 1);
}
Starting from 10, the result is 7. By convention the parameter is the first letter of the state name (s) or prev.
A mixed queue is processed in order. Suppose seats is 5:
setSeats(seats + 5); // queued: "replace with 10"
setSeats(s => s * 2); // queued: "apply to previous result" -> 20
setSeats(42); // queued: "replace with 42"
The final value is 42: the last replacement wins. Updater functions must be pure; they may be called twice in development under StrictMode to expose bugs.
| Situation | Use |
|---|---|
Set a value that does not depend on the old one (setOpen(false)) |
Direct value |
| Increment, toggle, or append based on the old value | Updater function |
| Several updates of the same state in one handler | Updater functions |
| Update in a delayed callback (timer, promise) that may see an old snapshot | Updater function |
6. Objects and arrays: never mutate state
State may hold any JavaScript value, but you must treat objects and arrays as read-only and always supply a new one. React decides whether to re-render by comparing the old and new values with Object.is. If you mutate the existing object and pass it back, the reference is the same, so React may skip the render and the screen goes stale.
const [booking, setBooking] = useState({ name: '', seats: 1, note: '' });
// WRONG: mutates the existing object
function wrong() {
booking.seats = 2;
setBooking(booking);
}
// RIGHT: creates a new object, copying the other fields
function right() {
setBooking({ ...booking, seats: 2 });
}
Arrays follow the same rule. Use non-mutating methods.
| Goal | Avoid (mutates) | Prefer (returns new array) |
|---|---|---|
| Add | push, unshift |
[...items, item], [item, ...items] |
| Remove | pop, splice, shift |
items.filter(i => i.id !== id) |
| Replace | items[0] = x |
items.map(i => i.id === id ? { ...i, done: true } : i) |
| Sort or reverse | sort, reverse on the state array |
[...items].sort(...) or toSorted |
The spread syntax copies only one level. For a nested object, copy each level you change: setBooking({ ...booking, guest: { ...booking.guest, name: 'Amira' } }). If nesting becomes painful, flatten the state or use a reducer (chapter 6).
7. Choosing what is state
State is for data that changes over time and affects what is rendered. Ask these questions about each value:
- Does it change over time? If not, it is a constant or a prop.
- Is it passed from a parent through props? If yes, it is not this component's state.
- Can it be computed from other state or props? If yes, it is a derived value, not state.
- Does changing it affect the display? If not (a timer id, for instance), it may belong in a ref (chapter 6).
Example: a list of workshops and a search text. The search text is state. The filtered list is derived and should be computed during rendering.
const [query, setQuery] = useState('');
const visible = workshops.filter(w =>
w.title.toLowerCase().includes(query.toLowerCase())
);
Storing visible in a second state would require keeping it in sync, a classic source of bugs.
8. Lifting state up
When two components need the same changing data, move the state to their closest common parent and pass it down as props, together with callbacks to change it.
function BookingPage() {
const [seats, setSeats] = useState(1);
return (
<>
<SeatPicker seats={seats} onChange={setSeats} />
<PriceSummary seats={seats} pricePerSeat={45} />
</>
);
}
function SeatPicker({ seats, onChange }) {
return (
<button onClick={() => onChange(seats + 1)}>Add seat ({seats})</button>
);
}
function PriceSummary({ seats, pricePerSeat }) {
return <p>Total: {seats * pricePerSeat} EUR</p>;
}
Data goes down as props; changes go up as callbacks. PriceSummary has no state; the total is derived. This keeps one source of truth.
Two related ideas:
- Resetting state. State belongs to a position in the tree. If a component is removed, its state is lost. You can reset a component's state by changing its
keyprop, which makes React treat it as a new instance. - Preserving state. Rendering the same component type at the same position keeps its state across re-renders, even if its props change.
Worked example: a reservation card
Problem. Build a WorkshopCard that starts with the number of seats from props, lets a visitor reserve one seat at a time with a button, allows cancelling, never goes below 0 or above the capacity, and shows "Sold out" at 0.
Step 1: decide the state. Props supply title and capacity. The only changing value is how many seats the visitor has reserved. Seats left is capacity - reserved, a derived value. We store reserved.
Step 2: write the component.
import { useState } from 'react';
export default function WorkshopCard({ title, capacity }) {
const [reserved, setReserved] = useState(0);
const seatsLeft = capacity - reserved;
const soldOut = seatsLeft === 0;
function handleReserve() {
setReserved(r => Math.min(r + 1, capacity));
}
function handleCancel() {
setReserved(r => Math.max(r - 1, 0));
}
return (
<article>
<h3>{title}</h3>
<p>{soldOut ? 'Sold out' : `${seatsLeft} seats left`}</p>
<button onClick={handleReserve} disabled={soldOut}>Reserve</button>
<button onClick={handleCancel} disabled={reserved === 0}>Cancel</button>
</article>
);
}
Step 3: trace it. With capacity={2}: initial reserved = 0, so the card shows "2 seats left", Reserve enabled and Cancel disabled. Click Reserve: the updater runs with r = 0 and returns 1; the card re-renders showing "1 seats left" (you would fix the grammar later with a conditional). Click again: reserved = 2, seatsLeft = 0, so "Sold out" and Reserve is disabled. A third click on the disabled button does nothing. Click Cancel: reserved = 1.
Step 4: why not the alternatives?
- Storing
seatsLeftin state as well would need two setters called together each time; forgetting one produces a card that says "Sold out" while still showing a positive count. - Writing
setReserved(reserved + 1)works for a single click but fails if the handler ever calls it twice, and is less safe in delayed callbacks. - Mutating a variable with
letloses the value on the next render. - Calling
onClick={handleReserve()}would update state during rendering and cause an infinite loop warning.
Common pitfalls
- Calling the handler (
onClick={fn()}) instead of passing it. - Mutating state (
push, assigning to a property) and expecting a re-render. - Expecting state to change immediately after calling the setter in the same handler.
- Calling hooks conditionally or inside loops.
- Storing derived data in state, which then drifts out of sync.
- Copying props into state without a reason (
useState(props.value)ignores later prop changes). - Using the index as
keyin lists whose order can change, which mixes up state between items. - Setting state during rendering (outside an event or effect), which creates loops.
- Forgetting that spread is shallow when updating nested objects.
Exam tip: Questions often show several
setX(x + 1)calls in one handler and ask for the result: they all use the same snapshot, so the total increase is one. The same code withsetX(x => x + 1)increases by the number of calls. If the question mentions a mutation of an array or object in state, the expected fix is a new copy.
Mini case study: the booking counter that jumps
Situation. A teammate's "Add 2 seats" button should raise the count by two, and the "Clear" button should empty the notes list. Users report that Add raises it only by one and Clear sometimes does nothing visible.
function BookingForm() {
const [seats, setSeats] = useState(1);
const [notes, setNotes] = useState(['vegetarian']);
function addTwo() {
setSeats(seats + 1);
setSeats(seats + 1);
}
function clear() {
notes.length = 0;
setNotes(notes);
}
return (
<>
<button onClick={addTwo}>Add 2 seats ({seats})</button>
<button onClick={clear}>Clear</button>
<ul>{notes.map(n => <li key={n}>{n}</li>)}</ul>
</>
);
}
Questions. (1) Why does Add raise the count by only one? (2) Why does Clear fail to update? (3) Rewrite both handlers.
Solution.
- Both calls read the same snapshot (
seats = 1), so each queues "replace with 2". The final state is2, an increase of one. The updater form fixes it:setSeats(s => s + 1)twice yields3. notes.length = 0mutates the existing array andsetNotes(notes)passes the same reference. React sees no change (Object.isis true) and may skip the render. Even if something else later triggers a render, mutating state breaks predictability.- Fixed code:
function addTwo() {
setSeats(s => s + 1);
setSeats(s => s + 1);
}
function clear() {
setNotes([]);
}
Check: starting at 1, a click shows 3; Clear empties the list immediately.
Key terms
- Event handler: a function that runs in response to a user action.
- State: data a component remembers between renders.
useState: the hook that declares a state variable and returns its setter.- Render: React calling a component to compute its JSX.
- Commit: applying the differences to the real DOM.
- Snapshot: the fixed state value seen during one render.
- Batching: grouping several state updates into one render.
- Updater function: a function passed to a setter that receives the previous state.
- Immutability: treating state values as read-only and replacing them with copies.
- Lifting state up: moving state to the nearest common parent.
- Derived value: a value computed from state or props, not stored.
Summary checklist
- I pass handlers to
onClickand friends without calling them. - I can explain why local variables cannot replace state.
- I use
useStateat the top level and follow the rules of hooks. - I describe trigger, render, and commit.
- I know a state variable is a snapshot within one render.
- I use updater functions when the new value depends on the old one.
- I replace objects and arrays instead of mutating them.
- I compute derived values instead of storing them.
- I lift state to the closest common parent and pass callbacks down.
Official sources
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 5: React Basics (opens in a new tab)
- React: Responding to Events (opens in a new tab)
- React: State, a Component's Memory (opens in a new tab)
- React: Render and Commit (opens in a new tab)
- React: State as a Snapshot (opens in a new tab)
- React: Queueing a Series of State Updates (opens in a new tab)
- React: Updating Objects in State (opens in a new tab)
- React: Sharing State Between Components (opens in a new tab)
