After migrating a React class to a function component, a popover's measuring code that used to run in this.setState({ open: true }, () => this.measure()) now measures the old layout. Why, and how do you restore the ordering?
answer
- the setter takes no second argument
- state is a constant for this render
- the DOM has not been re-rendered yet
- key the work to the value, not the call site
- before paint versus after paint
basics
~20 sState setters in function components take no callback, so calling measure() right after setOpen(true) runs before React re-renders and commits — it measures the previous DOM. Move the measurement into a useLayoutEffect that depends on the state value.
solid answer
~50 s`this.setState(partialState, callback)` ran its callback after React re-rendered and committed, which is exactly what measuring code needs. A function component's setter takes no second argument, so the naive port calls `setOpen(true); measure();` inside the handler — but the state variable is a constant for that render and the DOM has not been updated yet, so `measure()` reads the pre-update layout. The fix is to express it as "after this state is applied", which is a `useLayoutEffect` keyed on `open`: it runs after React mutates the DOM but before the browser paints, matching where the class callback ran. Use `useEffect` instead when the result does not affect what the user sees this frame. There is a second, unrelated use of the old callback — reading the freshly updated value — and that one is answered by the updater form, `setCount(c => c + 1)`, not by an effect.
code
jsx · 14 linesimport { useLayoutEffect, useRef, useState } from 'react';
export function Popover({ open }) {
const ref = useRef(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
if (open && ref.current) {
setHeight(ref.current.getBoundingClientRect().height);
}
}, [open]);
return <div ref={ref}>{open ? `${height}px tall` : null}</div>;
}go deeper
Know that a useState setter takes no callback and does not change the variable you are holding — the value you have is fixed for the current render, and the new one arrives on the next.
Explain that React batches the update and re-renders afterwards, so both the state variable and the DOM are stale in the rest of the handler, and that the replacement is an effect that depends on the value.
Choose the right tier deliberately: useLayoutEffect for measuring, positioning or focus so nothing flickers; useEffect for work that can wait for the paint; flushSync only when non-React code must see the DOM synchronously.
Note that keying the work to the state value rather than to a call site is the structural improvement — one effect covers every path that opens the popover, where the class pattern relied on each caller remembering to pass the callback.
## What the class callback guaranteed ```jsx this.setState({ open: true }, () => { this.setState({ height: this.node.getBoundingClientRect().height }); }); ``` The second argument to `setState` ran **after** React processed the update, re-rendered and committed to the DOM. That gave the callback two useful properties at once: the state it just set was applied, and the DOM reflected it. Measuring code, focus management and "scroll to the thing that just appeared" all leaned on it. ## Why the literal port breaks ```jsx function Popover() { const [open, setOpen] = useState(false); function handleClick() { setOpen(true); measure(); // reads the DOM as it is right now — closed } } ``` Two things go wrong. First, `open` is a `const` for the duration of this render; calling the setter schedules an update, it does not reassign the variable, so anything after it in the handler still sees the old value. Second, React does not re-render synchronously inside the handler — updates are batched and processed afterwards, so the DOM has not changed yet when `measure()` runs. The measurement is of the previous layout, and because it is *usually* only one frame stale, the bug tends to show up as an occasional misplacement rather than a hard failure. ## The replacement The class callback answered the question "what should happen once this update is on screen?" In a function component that question is answered by an effect that depends on the value: ```jsx useLayoutEffect(() => { if (!open) return; setHeight(ref.current.getBoundingClientRect().height); }, [open]); ``` Use `useLayoutEffect` when the result changes what the user sees this frame — measuring, positioning, focusing, restoring scroll. It runs after the DOM mutation and before the browser paints, which is precisely where `componentDidUpdate` and the `setState` callback ran, so nothing flickers. Use plain `useEffect` when the work is logging, analytics or a request, so it does not block the paint. This is also more robust than the callback ever was: the effect fires whenever `open` becomes true, regardless of which code path set it. The class version only ran for the one call site that remembered to pass a callback. ## The other thing the callback was used for A large share of class-era `setState` callbacks were not about the DOM at all — they were about reading the value you just set, because `this.state` was not updated synchronously: ```jsx this.setState({ count: this.state.count + 1 }, () => console.log(this.state.count)); ``` That use has a different answer. The updater form gives you the pending value directly and composes correctly when several updates queue up in one batch: ```jsx setCount((c) => c + 1); ``` Conflating the two is the common mistake in review: an effect appears where an updater function was needed, adding a render for no reason. ## When synchronous really is required Occasionally non-React code has to observe the updated DOM before the handler returns — a third-party widget that must be told a new size immediately, or a `print()` that has to see the expanded content. `flushSync` from `react-dom` forces React to render and commit that update synchronously, so the DOM is up to date when the call returns. It defeats batching and costs a synchronous render, so it is an escape hatch justified by an interop requirement, not a convenience for ordering your own code. ## How to present it Name the mechanism first — the setter has no callback and state is a snapshot for the current render, so nothing after the setter sees the new value or the new DOM. Then give the replacement and, importantly, distinguish the two uses of the old callback: "after it is committed" is an effect keyed on the value, while "using the value I just set" is the updater form.
- Why choose useLayoutEffect rather than useEffect for this measurement?useEffect is deferred until after the browser paints, so the user would see the popover at its unmeasured position for one frame, then see it jump. useLayoutEffect runs after React mutates the DOM and before paint, matching where the class callback ran, so the corrected position is part of the same frame. It blocks painting, so reserve it for work that is visually load-bearing.
- Some class code used the setState callback just to log the new count. What replaces that?The updater form, `setCount(c => c + 1)`, which receives the pending value and composes when several updates are batched together. Adding an effect for it would be wasteful — you would render, run an effect and possibly render again to learn a value the updater already had. Effects are for 'after it is committed', not for 'what did that become'.
- When is flushSync the right tool here rather than an effect?When code outside React must observe the updated DOM before your handler returns — telling an imperative third-party widget its new size, or calling print() on freshly expanded content. flushSync forces a synchronous render and commit, giving up batching and paying for an extra synchronous pass, so it is an interop escape hatch rather than a way to sequence your own React code.
saying these in an interview costs you the question
- Calling the setter updates the state variable immediately
- The DOM is up to date on the next line after setState
- useState setters accept a second callback argument like setState did
- useEffect and useLayoutEffect are interchangeable for measuring
- Wrap it in setTimeout so the DOM has caught up