Get eBook BundleVolume I index
State and Events From Scratch

State Updates and Renders

Ishtmeet Singh @ishtms/July 20, 2026/15 min read
#react#state#rendering#batching#updater-functions

What actually happens when you call a React state setter?

I think the first assumption almost everyone makes is that React changes the variable right there. You call setCount(count + 1), so obviously count should now be one higher, right?

Well... try this.

jsx
function handleClick() {
  setCount(count + 1);
  console.log(count);
}

Say the button currently shows 0. You click it, and the console logs 0.

Which can be slightly annoying the first time you see it. We literally just asked React to make it 1, why are we still getting 0?

Because setCount() doesn't rewrite the count variable inside the function that's already running. It requests another render with new state. The current handler keeps the values from the render that created it.

That idea comes up everywhere with React state, so let's spend some time on it.

Every render gets its own state values

Suppose our component renders for the first time and count is 0.

During that render, React calls the component function, gives it count = 0, and the component creates a click handler which also sees that same count = 0.

Then we click the button. That exact handler runs and calls:

jsx
setCount(count + 1);

Since its count is 0, it requests 1.

React later renders the component again, and this new call gets count = 1. It also creates a new handler, and that new handler sees 1.

So if we write the sequence out:

text
render 1 gets count 0
render 1 creates a handler which sees count 0
click runs that handler
handler requests count 1

render 2 gets count 1
render 2 creates a handler which sees count 1

React doesn't go back into render one and somehow replace its local count variable with 1. That function call already happened.

And JavaScript closures still work normally here. A callback keeps access to values from the function call where it was created.

You can see this very clearly with a timeout:

jsx
function handleClick() {
  setCount(count + 1);

  setTimeout(() => {
    console.log(count);
  }, 1000);
}

Suppose count was 0 when you clicked.

One second later that callback still prints 0, even if the screen already shows 1.

Why? The timeout callback was created by the handler from the render where count was 0. It still has access to that render's value.

React docs call state a snapshot, and that's actually a pretty useful word for this. For one component render, the state values you got are fixed.

Calling a setter asks React to create another render with another set of values.

And please don't fix this by adding setTimeout() just so you can "wait for React state to update". That's usually a sign the code is depending on the wrong value.

If you already know the next value inside the handler, just calculate it there.

jsx
function handleClick() {
  const nextCount = count + 1;

  setCount(nextCount);
  console.log(nextCount);
}

Now you're logging the value you calculated, not expecting React to rewrite your existing variable.

React doesn't immediately render after every setter line

Now say a handler changes two pieces of state:

jsx
function handleSave() {
  setSaved(true);
  setMessage("Goal saved");
}

React can process these together.

It generally waits until your event handler finishes, then processes the queued updates and renders the next UI.

This is called batching.

Without batching, you could potentially get one render after setSaved(true) and then another after setMessage("Goal saved"). React can instead process the compatible updates together and commit the resulting UI.

The same thing also explains why reading the DOM immediately after a state setter can still give you the old DOM.

jsx
function handleSave() {
  setSaved(true);
  console.log(statusElement.textContent);
}

That setter requested another render. React hasn't necessarily committed the new DOM while this same handler is still running, so textContent can still contain whatever was on screen before the click.

Normally you shouldn't need to manually inspect React-managed DOM after setting state anyway. Your component should calculate the UI from state, and React DOM updates the actual elements after React renders.

Also, batching doesn't mean React merges all your state variables into one giant state value. Each state position is still separate. React is just processing multiple update requests together before rendering.

Why three setCount(count + 1) calls only give you one

This example looks strange until you apply the snapshot rule:

jsx
function handleAddThree() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Surely we're adding three?

Nope.

Let's say count is 0 for this render. Every line reads the same count.

So React effectively receives:

jsx
setCount(1);
setCount(1);
setCount(1);

All three lines calculated their value from count = 0.

There was no live count variable changing from zero to one, then one to two, then two to three while the handler ran.

You requested replacement state three times, and every request happened to request the same value.

So the resulting count is 1.

This is one of those React examples people memorize without understanding, and then it comes back later in less obvious code. Don't memorize the output. Follow which render the value came from.

Updater functions are different

Now change the code to this:

jsx
function handleAddThree() {
  setCount((current) => current + 1);
  setCount((current) => current + 1);
  setCount((current) => current + 1);
}

Now the result is 3.

What's different?

We're no longer calculating the next value ourselves from the count variable in the handler. We're giving React functions.

React queues those updater functions, and when it processes them, each updater receives the state produced by the previous update.

So starting from zero:

text
first updater gets 0, returns 1
second updater gets 1, returns 2
third updater gets 2, returns 3

Then the next render receives 3.

The parameter name isn't special, by the way.

This:

jsx
setCount((current) => current + 1);

and this:

jsx
setCount((previousCount) => previousCount + 1);

mean the same thing.

Use whatever name makes the code easiest to read.

The useful rule here is pretty simple: if your next state is calculated from the previous state, use the updater form.

jsx
setCount((count) => count + 1);

If the new value doesn't depend on the old value, just pass the value.

jsx
setStatus("complete");
setQuery("");

There's no reason to write:

jsx
setStatus(() => "complete");

unless you somehow enjoy making simple code look suspicious.

Direct values and updater functions go into the same queue

You can also mix them.

For example:

jsx
setCount(5);
setCount((current) => current + 1);

React processes these in order.

First we request replacement state 5. Then the updater receives 5 and returns 6.

So the next state is 6.

Now reverse them:

jsx
setCount((current) => current + 1);
setCount(5);

The updater runs first, but then the direct replacement says the resulting state should be 5.

So you end up with 5.

You probably won't write long chains mixing these two forms very often. If you do, read them from top to bottom and follow the state value through each queued update. Once there's six different setters mixed together, maybe the handler wants simplifying anyway.

Updater functions should only calculate state

Since React can run updater functions while it's processing queued state, those functions need to behave predictably.

Don't do this:

jsx
setLesson((current) => {
  current.complete = true;
  return current;
});

There are two problems here.

We're changing the existing object, and then we're returning that exact same object reference.

Write this instead:

jsx
setLesson((current) => ({
  ...current,
  complete: true,
}));

Now we're returning another object containing the updated data.

Also don't put unrelated work inside an updater:

jsx
setCount((current) => {
  saveCountToServer(current + 1);
  return current + 1;
});

That's bad code because the updater now does more than calculate state. React expects updater functions to be pure, and development checks can call them again.

Do the event work in the event handler and let the updater calculate the value.

jsx
function handleClick() {
  setCount((current) => current + 1);
}

If saving needs to happen because of the user action, handle that separately based on what the application needs.

Objects in state should be replaced, not edited

State doesn't have to be a number or string. You can store objects too.

jsx
const [lesson, setLesson] = useState({
  title: "State Updates",
  complete: false,
});

Now you might be tempted to do:

jsx
lesson.complete = true;
setLesson(lesson);

JavaScript allows it. The object itself is mutable.

But React code shouldn't update stored state that way.

The lesson object here came from an existing render. By changing lesson.complete directly, you've modified the same object React was already using for that state.

Then setLesson(lesson) passes React the same reference again.

React uses Object.is() when comparing state values, so React can see the same object reference and decide there isn't a new state value to process.

And there's another problem too: you've changed an older render's state object after that render happened.

That makes debugging much harder because the value you thought belonged to an older render has now been edited later.

Instead, create another object:

jsx
setLesson((current) => ({
  ...current,
  complete: true,
}));

The spread copies the existing top-level fields, then complete: true replaces that property in the new object.

If your object contains nested objects, spread is only a shallow copy.

Say the state looks something like this:

jsx
{
  title: "State Updates",
  author: {
    name: "Ish",
    verified: false
  }
}

If you're changing author.name, you need another object at that level too:

jsx
setLesson((current) => ({
  ...current,
  author: {
    ...current.author,
    name: "Ishtmeet",
  },
}));

You're creating a new outer object and a new author object, while values that didn't change can stay as they were.

Arrays follow the same rule

Suppose state contains lessons:

jsx
const [lessons, setLessons] = useState([]);

To add a lesson, create another array:

jsx
setLessons((current) => [
  ...current,
  newLesson,
]);

To remove one:

jsx
setLessons((current) =>
  current.filter((lesson) => lesson.id !== lessonId),
);

And to update one item:

jsx
setLessons((current) =>
  current.map((lesson) =>
    lesson.id === lessonId
      ? { ...lesson, complete: true }
      : lesson,
  ),
);

Notice what happens in that map().

We create a new array. The lesson being changed gets a new object. Lessons that didn't change can keep their existing references.

That's exactly what we want.

Methods such as push(), pop(), splice(), sort(), and reverse() change the existing array, so don't call them directly on the current state array.

Use operations which return another array, such as map(), filter(), toSorted(), or spread syntax.

Setting the same value can result in no update

React compares the requested next state with the current state using Object.is().

So:

jsx
setCount(0);

when count is already 0 doesn't give React any new state.

There may be nothing to update.

With objects, reference identity comes into it:

jsx
Object.is({}, {}); // false

const lesson = {};
Object.is(lesson, lesson); // true

Two separately created objects are two different references, even if both currently contain the exact same fields.

That's why creating a new object tells React you have another state value.

But don't take this to the other extreme and create fresh objects for no reason every time some unrelated event happens.

If the stored information didn't change, returning the existing state is completely fine.

jsx
setLesson((current) => {
  if (current.complete) {
    return current;
  }

  return {
    ...current,
    complete: true,
  };
});

And don't start adding deep-equality checks inside every setter either. Usually your update logic already knows whether anything changed.

One more small detail: React can sometimes call your component before it decides that a same-value update doesn't need further work. So application behavior shouldn't depend on the assumption that a component function will never run when the state eventually stays the same.

Keep render code pure and this isn't a problem.

What happens after state is queued?

Let's put the whole update sequence together.

Say the user clicks this button:

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

  return (
    <section>
      <h2>Lesson counter</h2>

      <button onClick={() => setCount((n) => n + 1)}>
        {count}
      </button>
    </section>
  );
}

The click handler runs with the state values from its render. setCount() queues an updater. The handler finishes. React processes the update, calls the component again with the next count, and calculates the next React output.

Then React DOM checks what actually changed in the browser DOM and applies those changes.

In this example, the heading:

jsx
<h2>Lesson counter</h2>

still says the exact same thing.

React doesn't need to remove it and create it again just because Counter() ran again.

The button text did change, so React DOM updates that part.

You can describe the sequence as:

text
button click
  -> handler reads values from its render
  -> setter queues an update
  -> handler finishes
  -> React calculates the next component output
  -> React DOM applies required DOM changes
  -> browser displays the updated result

A state update can cause a component render without every DOM element returned by that component being changed.

Remember from the previous chapter: React rendering and browser DOM mutation aren't the same operation.

Strict Mode makes bad updater code easier to notice

The Vite React setup normally has something along these lines:

jsx
<StrictMode>
  <App />
</StrictMode>

In development, Strict Mode can intentionally repeat some render-related work.

This confuses people because they add a console.log() to a component and suddenly see it twice.

And then the first instinct is sometimes, "React is broken, remove Strict Mode."

Please don't.

React is trying to expose code that behaves differently when run more than once.

For example, this component changes its input:

jsx
function LessonList({ lessons }) {
  lessons.push({ id: "extra", title: "Extra" });

  return <p>{lessons.length}</p>;
}

Every time the component runs, it pushes another item into the same array.

Run it twice and now you've changed the result twice.

That's exactly the sort of mutation Strict Mode can make easier to spot.

The component should calculate output without editing incoming data.

Updater functions can also be called again by development checks, so this:

jsx
setCount((current) => current + 1);

is safe because calling it with the same input returns the same output and doesn't modify anything elsewhere.

Strict Mode does not mean one user click calls your click handler twice.

One click still runs one event handler. React may repeat certain render or updater calculations afterward during development checks.

State setters themselves stay stable

When you write:

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

the count binding can be different on every render.

Render one might have count = 0, render two might have count = 1, and so on.

But React keeps the setCount function identity stable for that state position.

So passing a setter or a callback which uses that setter is completely normal.

jsx
<CounterControls
  onIncrement={() => setCount((n) => n + 1)}
/>

Usually I prefer passing callbacks named after what the user can actually do, such as onIncrement, instead of exposing a raw setter to another component. It says more about what that child is allowed to request.

And none of this counter logic needs an Effect.

User clicks button, handler queues state, React renders. Done.

Don't add an Effect just because state changed.

Debugging one state update

If state timing still feels confusing, add two temporary logs.

jsx
function handleClick() {
  console.log("handler snapshot", count);
  setCount((current) => current + 1);
}

console.log("render snapshot", count);

Now click once.

The handler log tells you which value the click handler received from its render. The render log tells you which value the component sees whenever React calls it.

With Strict Mode enabled in development, you might get more render logs than you expected. That's okay. The click handler itself still ran once for one click.

Then open React DevTools and check the current component state. Open the browser Elements panel and check the DOM text React DOM actually committed.

After you've understood the sequence, delete the logs. Otherwise six chapters later you'll open the console and wonder why your app apparently has opinions about "handler snapshot".

Check if you can predict these now

Before running each example, try saying what happens.

jsx
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

All three lines use count from the same render, so if count was zero, the next state is one.

Now:

jsx
setCount((n) => n + 1);
setCount((n) => n + 1);
setCount((n) => n + 1);

Each updater receives the queued result from the previous updater, so zero becomes three.

And:

jsx
setCount(5);
setCount((n) => n + 1);

becomes six.

While:

jsx
setCount((n) => n + 1);
setCount(5);

ends at five.

If those results make sense without memorizing them, then you've got the useful part of React's state model.

State values belong to a particular render. Setters queue later state. Updater functions receive queued state in order. Objects and arrays should be replaced when their stored data changes. React then renders using the resulting state, and React DOM only changes the browser nodes that actually need updating.

That's pretty much the entire mechanism we need for the next few sections.