React & Next.js

State and useState

A plain JavaScript variable inside a component doesn't survive a re-render, and changing it doesn't trigger one either. useState is the specific mechanism that fixes both problems at once — and understanding why a plain variable fails is what makes useState make sense.

Beginner

4 min read

Why a plain variable doesn't work for anything that needs to change

function Counter() {
  let count = 0; // this looks like it should work — it doesn't
 
  function handleClick() {
    count = count + 1;
    console.log(count); // this DOES increase — the variable itself updates fine
  }
 
  return <button onClick={handleClick}>Clicked {count} times</button>;
  // but the screen NEVER updates — React has no idea count changed
}

count genuinely does increment here — that's not the problem. The problem is twofold: first, every time Counter re-renders (for any reason), the function runs again from the top, and let count = 0 resets it right back to zero, discarding whatever value it held before. Second, and more fundamentally, React has no way of knowing a plain local variable changed at all — nothing about reassigning a normal JavaScript variable tells React "hey, go re-run this component and update the screen." A local variable in a component is completely invisible to React's rendering machinery.

useState: a variable React actually knows about

import { useState } from "react";
 
function Counter() {
  const [count, setCount] = useState(0);
 
  function handleClick() {
    setCount(count + 1);
  }
 
  return <button onClick={handleClick}>Clicked {count} times</button>;
}

useState(0) returns a pair: the current value (count), and a function to update it (setCount) — the array-destructuring syntax [count, setCount] is just how that pair is unpacked, nothing React-magic about the syntax itself. Calling setCount(newValue) does two things a plain reassignment never could: it tells React "this component's state actually changed, please re-render it," and — critically — React persists the value across re-renders on its own, outside the component function's own local scope, so it survives the fact that the function body runs again from scratch every time.

State updates are asynchronous — and batched

function handleClick() {
  setCount(count + 1);
  console.log(count); // still logs the OLD value — the update hasn't happened yet
}

Calling setCount doesn't update count immediately, in place, the way a plain assignment would — it schedules a re-render, and the new value only exists once that re-render actually happens, producing a fresh count for that next call to the component function. The count variable inside the current run of handleClick never changes; it's a snapshot of what count was when this particular render started. React also batches multiple state updates that happen in the same event handler into a single re-render, rather than re-rendering once per setCount call — a real performance optimization, not an accident.

The functional updater form: fixing a real, common bug

function handleTripleClick() {
  setCount(count + 1); // all three of these read the SAME snapshot of count —
  setCount(count + 1); // count from when this render started, unchanged
  setCount(count + 1); // net result: count increases by only 1, not 3
}
 
function handleTripleClickFixed() {
  setCount((prev) => prev + 1); // each one receives the LATEST value, including
  setCount((prev) => prev + 1); // updates from the calls immediately before it
  setCount((prev) => prev + 1); // net result: count genuinely increases by 3
}

Because count inside a single render is a fixed snapshot, calling setCount(count + 1) three times in a row computes count + 1 from the same stale value each time — not from whatever the previous setCount call in the same batch just set. Passing a function to setCount instead ((prev) => prev + 1) tells React "compute the new value from whatever the most current value actually is," including updates from earlier calls in the same batch — this is the standard, correct pattern any time a new state value genuinely depends on the previous one, rather than on some other independent value.

State is local to the component instance that owns it

function App() {
  return (
    <>
      <Counter /> {/* has its own, independent count */}
      <Counter /> {/* a completely separate count, starting from 0 too */}
    </>
  );
}

Each rendered instance of a component gets its own, completely independent copy of its state — two <Counter /> elements on the same page don't share a count, clicking one doesn't affect the other's displayed number at all. This is a direct consequence of state being tied to a specific position in the component tree, not to the Counter function itself (the same function is just called twice, producing two independently-tracked pieces of state) — a distinction that matters more once conditional rendering and lists start moving components around, covered in this domain's keys-and-reconciliation lesson.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why doesn't a plain `let count = 0;` local variable work for something that needs to update the screen?

2. Why does `console.log(count)` right after calling `setCount(count + 1)` still print the OLD value?

3. Why does calling `setCount(count + 1)` three times in the same event handler only increase count by 1, not 3?