EduFlow
React Fundamentals
0%

Section 3 · State & Events

useState Fundamentals

20 min3 min readCode playground

Lesson at a glance

Give components memory with useState, understand state as a per-render snapshot, use updater functions for successive updates, and replace arrays and objects instead of mutating them.

  1. 1useState
  2. 2Every render is a snapshot
  3. 3Arrays and objects: replace, don’t mutate
  4. 4Choosing the initial value

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Click a “Mark as watched” button in a plain function component and nothing happens. Here’s the code most people write first:

JSX
export default function MovieItem() {
  let watched = false;

  function handleClick() {
    watched = !watched;
  }

  return (
    <button onClick={handleClick}>
      {watched ? 'Watched' : 'Mark as watched'}
    </button>
  );
}

Two things go wrong. Changing a local variable doesn’t tell React anything happened, so it never renders again. And even if something else caused a render, let watched = false would run again and reset the value, because a component is a function and its local variables start fresh on every call. A component needs two things: memory that survives between renders, and a way to ask React to render again. That’s state.

useState

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

export default function MovieItem({ title }) {
  const [watched, setWatched] = useState(false);

  return (
    <li>
      {title}
      <button aria-pressed={watched} onClick={() => setWatched(!watched)}>
        {watched ? 'Watched' : 'Mark as watched'}
      </button>
    </li>
  );
}

useState(false) declares one piece of state with an initial value of false. It returns a pair, which you destructure and name: the current value, and a setter function. The [thing, setThing] naming is a convention everyone follows; stick to it.

When you call setWatched(true), React stores the new value and schedules a re-render. It calls MovieItem again, and this time useState returns true. Your JSX is calculated from that, and React updates the DOM to match.

State belongs to a component instance, not to the function. Render <MovieItem /> three times and you get three independent watched values. Clicking one doesn’t affect the others.

Every render is a snapshot

This is the idea that makes state click. When React calls your component, it hands you the state for that render. Everything in that render, the JSX, the event handlers, all of it, sees those values. Setting state doesn’t change the variable you already have; it requests a new render with a new value.

Render 1 sees watched equals false. A click calls setWatched(true), which schedules render 2. Render 2 sees watched equals true, and React commits the changed DOM. Render 1 watched = false Click setWatched(true) Render 2 watched = true user acts React re-renders Each render keeps its own values; nothing changes mid-render
Setting state triggers a new render with a new snapshot

That’s why this surprises people:

JSX
const [count, setCount] = useState(0);

function addThree() {
  setCount(count + 1); // count is 0 → asks for 1
  setCount(count + 1); // count is still 0 → asks for 1
  setCount(count + 1); // still 0 → 1
}

All three calls read the same snapshot, so the result is 1, not 3. When the next value depends on the previous one, pass an updater function instead of a value:

JSX
function addThree() {
  setCount((c) => c + 1); // 0 → 1
  setCount((c) => c + 1); // 1 → 2
  setCount((c) => c + 1); // 2 → 3
}

React queues the updaters and runs them in order, each receiving the result of the one before. You don’t need updaters everywhere; setWatched(!watched) in a click handler is fine. Use them when you set the same state more than once in a handler, or in code that runs later, such as a timer.

Arrays and objects: replace, don’t mutate

The Watchlist keeps an array of movie objects in state. React decides whether state changed by comparing the old and new value with Object.is. If you change an array in place and pass the same array back, React sees the same reference and may skip the render entirely.

So treat state as read-only and build new values:

JSX
const [movies, setMovies] = useState(INITIAL_MOVIES);

// Add: a new array with everything plus one more
setMovies([...movies, { id: crypto.randomUUID(), title: 'Dune', watched: false }]);

// Remove: a new array without one item
setMovies(movies.filter((m) => m.id !== id));

// Update one: a new array, with a new object for the changed item
setMovies(movies.map((m) => (m.id === id ? { ...m, watched: !m.watched } : m)));

These three lines (spread to add, filter to remove, map with a spread to update) cover most of the state updates you’ll ever write for lists. Objects work the same way: setFilters({ ...filters, year: 2024 }).

Choosing the initial value

The argument to useState is only used on the first render. Later renders ignore it. If computing the initial value is expensive, such as reading and parsing saved data, pass a function instead, useState(() => loadMovies()), and React calls it only on the first render instead of on every render. You’ll use exactly that to load the Watchlist from localStorage in Section 4.

Now your components can remember things. Next, you’ll look closely at the events that change state: click, keyboard and the event object itself.

Key takeaways

  • useState returns the current value and a setter; calling the setter schedules a re-render with the new value.
  • Each render sees a fixed snapshot of state, so reading state right after setting it still gives the old value.
  • Use an updater function, setCount((c) => c + 1), when the next value depends on the previous one.
  • Never mutate state; create new arrays and objects with spread, map and filter so React notices the change.
  • Call Hooks at the top level of a component, never inside conditions, loops or nested functions.

Further reading

Check your understanding

4 questions

Question 1 of 4

count starts at 0. What does the button show after one click?

JSX
function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}
Question 2 of 4

Why doesn’t this toggle work? let watched = false; inside the component, and the button’s handler does watched = !watched;.

Question 3 of 4

Which correctly marks one movie as watched in movies state?

Question 4 of 4

Inside a click handler you call setTitle('Dune') and then console.log(title) on the next line. The old title was ‘Arrival’. What’s logged?

Practice

Code playground

Watched toggle and a rating

This movie card has two broken features.

  1. Mark as watched (#toggle) does nothing. Make it toggle between Mark as watched and Watched, with aria-pressed following along.
  2. The rating: +2 (#plus-two) only adds 1, and the rating can climb past 5. Make +2 add two, and never let the rating go above 5, whichever button is clicked.
import { useState } from 'react';

export default function App() {
  let watched = false;
  const [rating, setRating] = useState(0);

  function toggleWatched() {
    watched = !watched;
  }

  function addOne() {
    setRating(rating + 1);
  }

  function addTwo() {
    setRating(rating + 1);
    setRating(rating + 1);
  }

  return (
    <article>
      <h2>Arrival</h2>
      <button id="toggle" aria-pressed={watched} onClick={toggleWatched}>
        {watched ? 'Watched' : 'Mark as watched'}
      </button>
      <p id="rating">Rating: {rating}/5</p>
      <button id="plus" onClick={addOne}>+1</button>
      <button id="plus-two" onClick={addTwo}>+2</button>
    </article>
  );
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Clicking the toggle shows “Watched” and sets aria-pressed to true
  • Clicking again shows “Mark as watched” and aria-pressed false
  • +2 raises the rating from 0 to 2
  • The rating stops at 5