React path · Time and place · Topic 11 of 12

React and the event loop

Not started

React runs on the same single thread and event loop as the rest of your page. A state update doesn't change the DOM where you call it: React queues it, and then renders and commits later, at a point it chooses based on what caused the update. For clicks and key presses that point is a microtask right after your handler, before the browser paints. For timers, network responses and transitions it is a task scheduled for shortly afterwards. Knowing which tells you when the DOM is up to date, and why a long render makes the page stop responding.

Why it matters: Reading the DOM right after `setState`, measuring before a paint, or a render so long that clicks queue up: all of these depend on where React does its work in the browser's event loop.

  1. 1Setting state only queues

    `setState` records the update and asks React to render. The DOM still shows the old value on the next line, and in any microtask you queued before it.

  2. 2Clicks and keys are flushed before paint

    For a discrete event such as a click, React renders and commits the batch in a microtask right after your handler, so the user never sees a frame without the update.

  3. 3Other updates get a task

    Updates from timers or network callbacks are batched too, then rendered in a task React schedules. `flushSync` forces a synchronous commit when you need the DOM now.

  4. 4Rendering holds the main thread

    A render is JavaScript on the main thread. A long urgent render blocks input until it ends; a transition yields between pieces so input can run.

1

How it works

The explanation, with examples you can run.

Queue now, render soon

This toy store (not React's code) shows the general technique: collect updates while the current code runs, and render once in a microtask.

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

The three updates produce one render, after the synchronous code and before the next task. React's batching for a click works the same way from the outside.

What React 19 does, by cause

These timings were observed in Chrome with React 19. They are implementation details that React may change, but they explain the behaviour you see:

  • A click, key press or other discrete event. The updates from the handler are rendered and committed in a microtask scheduled when the first update was queued. Microtasks you queued before that still see the old DOM; anything after, including the next frame, sees the new one. Effects caused by the update may also run before the paint, which the useEffect docs mention.
  • A timer, a network response, a message. The updates are batched in the same way (automatic batching, since React 18), but rendered in a task React schedules. Your remaining code and its microtasks see the old DOM, and passive effects usually run after the paint.
  • After an await in a handler. Updates made after the await are a new batch and a separate render, because the handler's synchronous part has already been processed.
  • A transition. Rendered in tasks, yielding to the browser regularly, and abandoned if a more urgent update arrives.

When you need the DOM to reflect an update right now (to scroll to a newly added item, or before handing control to a browser API), flushSync from react-dom renders and commits synchronously:

import { flushSync } from "react-dom" declare const listRef: { current: HTMLUListElement | null }declare function setItems(update: (prev: string[]) => string[]): void export function addAndScroll(item: string) {  flushSync(() => setItems((prev) => [...prev, item]))  listRef.current?.lastElementChild?.scrollIntoView() // the new item is in the DOM}

Use it sparingly: it gives up batching and can hurt performance.

Rendering blocks the thread

Rendering is ordinary JavaScript. While React renders an urgent update, nothing else on the main thread can run: no input handlers, no timers, no painting.

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

The timer was due at once, but it runs only after the 200 ms of synchronous work (the exact number printed varies, but is never below 200). A click during a slow render waits in the same way, which shows up as poor responsiveness (INP). The fixes are the ones from earlier topics: render less (memoisation, moving state down), split the work, or mark the slow part as a transition so React can pause between components and let input through.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.In a click handler, you call `setOpen(true)` and on the next line read `panelRef.current` to measure the panel. It is still `null`. Why?
  2. 2.Inside a `setTimeout` callback, you call `setCount(5)` and queue a microtask that reads the counter's text from the DOM. What does it read, in React 19?
  3. 3.A click handler starts a render that takes 300 ms. The user clicks a second button during it. What happens?

0 of 3 right so far. Get all three to finish this topic.