React

useState and the Mental Model of State

A clear look at what useState actually does in React, why state updates feel async, and how thinking about snapshots stops the confusion.

State was the first thing I found genuinely confusing about React. Not the syntax — useState is a one-liner. The confusion was about what state is and why it behaves in ways that feel wrong when you first run into them.

The biggest trap: I thought state was a variable I could update and immediately read. It isn't. Getting that mental model right changed how I wrote React code.

State is a Snapshot, Not a Variable

When you call useState, React gives you a value and a setter:

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

That count value is frozen for the entire render. It's not a mutable variable — it's a snapshot of what state was when this render happened. If you call setCount, React schedules a new render with a new snapshot. The current render's count never changes.

This is why code like this is surprising at first:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    console.log(count); // still 0
  }

  return <button onClick={handleClick}>{count}</button>;
}

Click the button and count goes to 1, not 3. All three setCount calls see the same snapshot where count is 0. And the console.log still reads 0 because the current render's snapshot hasn't changed.

If you want the previous state when computing the next state, pass a function:

setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);

Now React queues three updater functions and applies them in sequence. Count goes to 3.

The rule: use the function form whenever the new state depends on the previous state.

React Batches State Updates

Multiple setState calls inside a single event handler don't trigger multiple renders. React batches them into one render. This has been true for event handlers since React's early days, and since React 18 it also applies to async functions and setTimeout.

function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  function handleReset() {
    setName('');      // batched
    setEmail('');     // batched — one render, not two
  }

  return (/* ... */);
}

Batching is why you can call several setters together without worrying about performance. React is smarter than it looks.

Initialising State Correctly

useState accepts an initial value, but that initial value is only used on the first render. Every render after that, React ignores it.

// This is fine — a literal is cheap
const [count, setCount] = useState(0);

// This runs parseData() on every render, even though the result is only used once
const [data, setData] = useState(parseData(rawInput));

// This only runs parseData() once — pass a function
const [data, setData] = useState(() => parseData(rawInput));

The lazy initialiser pattern (passing a function instead of a value) matters when the initial calculation is expensive. If it's cheap, don't bother.

State That Changes Together Should Live Together

When two pieces of state always update at the same time, they belong in one state object:

// Two separate states that move in sync — awkward
const [lat, setLat] = useState(0);
const [lng, setLng] = useState(0);

// Better: one state object
const [position, setPosition] = useState({ lat: 0, lng: 0 });

But don't bundle everything. State that changes independently should stay separate. Over-grouping makes updates verbose and creates subtle bugs when you forget to spread the previous value:

// Easy to break — forgetting the spread loses the other key
setPosition({ lat: newLat }); // lng is now undefined

// Correct
setPosition(prev => ({ ...prev, lat: newLat }));

A practical guide: if you find yourself writing the spread every time you update, the state might be better split.

Derived Values Don't Need State

One of the common over-uses of useState is storing values you could compute from existing state or props:

// Don't do this
const [fullName, setFullName] = useState(`${firstName} ${lastName}`);

// Just compute it
const fullName = `${firstName} ${lastName}`;

Every time firstName or lastName changes, you'd need to remember to update fullName too. There's no benefit — and a new bug surface. Compute it inline.

The same applies to filtered lists, totals, formatted strings. If you can derive it, derive it. State is for values that React can't compute from what it already knows.

When to Reach for useReducer Instead

useState gets awkward when:

  • Multiple state values are deeply related
  • The next state depends on complex logic, not just the previous value
  • You have many event handlers all touching the same state

At that point, useReducer is cleaner. But for most component state — a toggle, a form field, a list of items — useState is the right tool.

The Mental Model in One Sentence

Each render is a snapshot. State doesn't mutate; React creates a new snapshot when you call a setter and schedules a re-render with that new value.

Once that clicked for me, a lot of the "unexpected" behaviour stopped being surprising. The stale closure in a useEffect. The console.log that shows the old value. The update that didn't seem to apply. All of it traces back to the snapshot model — and all of it makes sense once you've accepted that state is not a mutable variable.

← Older
When to Use useMemo and useCallback
Newer →
useReducer for Complex State

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.