Skip to content

React Components, State, Forms, and Routing

Step 20 of 45

React Events and State: Rendering and Immutable Updates

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 introduced React components and read-only props for displaying parent-supplied data. 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 trace what a component shows after a sequence of clicks and explain the state transitions, without relying on assumptions about an official exam question distribution.

Learning objectives

  1. Attach event handlers to elements and distinguish passing a function from calling it.
  2. Explain what state is and when a value should be state rather than a variable or a prop.
  3. Use useState with primitive, object, and array values.
  4. Describe the render cycle: trigger, render, commit.
  5. Explain why state updates are scheduled, and why a state variable is a snapshot within one render.
  6. Use updater functions when the next state depends on the previous one.
  7. Update objects and arrays immutably.
  8. 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.

JSX
function ReserveButton() {
  function handleClick() {
    alert('Seat requested');
  }
  return <button type="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.

JSX
function SeatCounter() {
  let seats = 10;

  function handleReserve() {
    seats = seats - 1;
    console.log(seats);
  }

  return (
    <>
      <p>Seats left: {seats}</p>
      <button type="button" onClick={handleReserve}>Reserve</button>
    </>
  );
}

With no intervening re-render, click the button three times. The console prints 9, 8, 7, but the screen still says Seats left: 10. Two things are wrong:

  1. Local variables do not persist between renders. Each time React calls SeatCounter, let seats = 10 runs again from scratch.
  2. 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

JSX
import { useState } from 'react';

function SeatCounter() {
  const [seats, setSeats] = useState(10);

  function handleReserve() {
    setSeats(seats - 1);
  }

  return (
    <>
      <p>Seats left: {seats}</p>
      <button type="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 for Hooks such as useState and useEffect:

  • 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.

The React use API has special rules that allow loops and conditions; it does not relax the rules for useState.

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 uses it during initialization, rather than every update. In development Strict Mode, it can run twice to check purity; keep it free of side effects.

JSX
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.

  1. 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).
  2. 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.
  3. 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.

JSX
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 batches updates so related changes can be committed together after an event handler finishes. Do not rely on an exact number of component invocations, especially in development. Separate intentional clicks are handled separately; a disabled button from one click can prevent the next.

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.

JSX
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:

JSX
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

Treat state objects and arrays as read-only. When changing them, create new copies along the changed path; unchanged objects can keep their references. Returning the existing state for a genuine no-op is fine. 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.

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

Apply JavaScript array methods and immutable updates when changing React collections; avoid mutating the stored array.

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:

  1. Does it change over time? If not, it does not need local state. Props themselves can change when the parent renders.
  2. Is it passed from a parent through props? If yes, it is not this component's state.
  3. Can it be computed from other state or props? If yes, it is a derived value, not state.
  4. 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.

JSX
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.

JSX
function BookingPage() {
  const [seats, setSeats] = useState(1);
  return (
    <>
      <SeatPicker seats={seats} onChange={setSeats} />
      <PriceSummary seats={seats} pricePerSeat={45} />
    </>
  );
}

function SeatPicker({ seats, onChange }) {
  return (
    <button type="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 key prop, 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 a fixed non-negative integer capacity. This is a local interaction exercise, not a reservation service. Live inventory changes need a separate reconciliation policy; for another workshop, give the card a new key so its state resets. 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.

JSX
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} ${seatsLeft === 1 ? 'seat' : 'seats'} left`}</p>
      <button type="button" onClick={handleReserve} disabled={soldOut}>Reserve</button>
      <button type="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 seat left". 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 seatsLeft in 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 let loses 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 key in lists whose order can change, which mixes up state between items.
  • Unconditionally setting state during rendering, which creates a loop. React documents a rare guarded adjustment pattern, but ordinary user-driven changes belong in event handlers.
  • Forgetting that spread is shallow when updating nested objects.

Study 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 with setX(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.

JSX
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 type="button" onClick={addTwo}>Add 2 seats ({seats})</button>
      <button type="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.

  1. Both calls read the same snapshot (seats = 1), so each queues "replace with 2". The final state is 2, an increase of one. The updater form fixes it: setSeats(s => s + 1) twice yields 3.
  2. notes.length = 0 mutates the existing array and setNotes(notes) passes the same reference. React sees no change (Object.is is true) and may skip the render. Even if something else later triggers a render, mutating state breaks predictability.
  3. Fixed code:
JSX
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 onClick and friends without calling them.
  • I can explain why local variables cannot replace state.
  • I use useState at 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