blog / that re-render bug that's actually a feature

that re-render bug that's actually a feature

  • Front-end
  • React

let's go straight to the code. what do you expect to happen here?

function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  console.log("after both sets");
}

the intuitive answer is usually "two renders, one per set call." that makes sense to think, since they're two separate state calls. except that's not what happens, react groups both updates and fires a single re-render with both values already updated. this is called batching.

for anyone just starting out: a "render" is the moment react redraws the screen with the current data. and "state" is just a fancy name for "the info a component keeps track of, which makes the screen change when it changes" (like the counter on a like button).

why this matters in practice

it's not just backstage trivia. it directly affects performance and, more subtly, can confuse anyone expecting to read a value "mid-way through":

function handleClick() {
  setCount((c) => c + 1);
  console.log(count); // still shows the old value!
}

the count you read in that console.log is still the value from before the click. that happens because updating state isn't instant, it's more like putting in an order at a restaurant: you "schedule" the change, and it only actually shows up on the next render. this trips up a lot of people coming from more imperative code, like vanilla js, where a variable changes right away, no queue involved.

think of it like asking the waiter to swap your plate. the request was made, but the new plate only arrives later, not the exact second you asked.

the real gotcha: it wasn't always like this EVERYWHERE

up until react 17, this batching only happened inside react's own event handlers (onClick, onChange, etc, meaning: functions that run when you click or type something). if you called setState inside a setTimeout, a resolved promise, or a native browser callback, each call triggered a separate render:

// react 17 and earlier
setTimeout(() => {
  setCount((c) => c + 1); // render 1
  setFlag((f) => !f);     // render 2
}, 1000);

two renders right there, no way around it, even though the code looks identical to the first example. the difference was purely in the context where setState was called, something you couldn't guess just by reading the function in isolation.

react 18 fixed this by introducing automatic batching. now, with createRoot (the modern way to start a react app), pretty much anywhere (setTimeout, promises, native listeners, whatever) behaves the same as an event handler. a click, a resolved promise, a timeout: everything groups updates the same way.

what if i don't want batching?

there's an escape hatch for that: flushSync, from react-dom. it forces an immediate synchronous render, without waiting for batching:

import { flushSync } from "react-dom";

function handleClick() {
  flushSync(() => {
    setCount((c) => c + 1);
  });
  // dom is already updated here, safe to read directly
  console.log(divRef.current.textContent);
}

use it sparingly. in practice, you'll almost never need this, it's more of a tool for rare cases (like measuring the dom immediately after a change) than something for everyday use.

what to take away from this

  • setState isn't instant, it gets put in a queue (it's "scheduled")
  • multiple setState calls at the same moment tend to become a single render (batching)
  • before react 18, this only applied inside react's own handlers, outside of that, each call was its own render
  • react 18 unified this behavior almost everywhere via automatic batching
  • flushSync exists for the rare cases where you actually need a synchronous render

at the end of the day, understanding this avoids two things: code that "should work but doesn't" (like reading freshly-updated state right after the set) and premature optimizations trying to manually "force" fewer renders when react is already handling it under the hood.

it's code, but it's also a bit philosophical: sometimes the tool already solved the problem before you even noticed it existed. neat, right? =]