A React tooltip renders at a default position, then reads the trigger's getBoundingClientRect() inside a useEffect and calls setState to move it. Users see the tooltip flash in the wrong place for one frame. Why does that happen, and what do you change?
answer
- the user sees one extra frame
- something runs between commit and paint
- commit, layout effects, paint, passive effects
- measure and correct before painting
- blocking the paint is the price
basics
~20 suseEffect runs after the browser has painted, so the unmeasured position is visible for one frame. Move the measure-and-reposition work into useLayoutEffect, which React runs synchronously after it mutates the DOM and before the browser paints.
solid answer
~50 sReact commits the DOM, runs every `useLayoutEffect` synchronously, then yields so the browser can paint, and only afterwards flushes passive `useEffect` callbacks. So a measure-then-`setState` written in `useEffect` produces two painted frames: one at the default position and one at the corrected position, which the user reads as a flash or a jump. Moving the same code into `useLayoutEffect` keeps the correction inside the commit — React reads the rect, my `setState` forces an immediate synchronous re-render and DOM update, and the browser paints only the final position. There are still two renders, but only one paint. The price is that a layout effect is on the critical path and blocks painting, so I keep it to one measurement and one state write, and leave anything that can wait — analytics, subscriptions, fetching — in `useEffect`.
code
javascript · 26 linesimport { useLayoutEffect, useRef, useState } from 'react';
function Tooltip({ anchorRef, children }) {
const tipRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const anchor = anchorRef.current;
const tip = tipRef.current;
if (!anchor || !tip) return;
const a = anchor.getBoundingClientRect();
const t = tip.getBoundingClientRect();
const next = { top: a.bottom + 8, left: a.left + a.width / 2 - t.width / 2 };
setPos((prev) =>
prev.top === next.top && prev.left === next.left ? prev : next
);
}, [anchorRef, children]);
return (
<div ref={tipRef} style={{ position: 'fixed', top: pos.top, left: pos.left }}>
{children}
</div>
);
}go deeper
Know the one-line rule: useEffect runs after the browser paints, useLayoutEffect runs before it, and anything that repositions or resizes visible UI from a measurement belongs in the second one.
Be ready to walk the commit timeline out loud — DOM mutation, layout effects, paint, passive effects — and explain that a setState inside a layout effect re-renders synchronously, so there are two renders but only one painted frame.
Show that you treat layout effects as frame budget: measure once, write once, guard the state update so it cannot loop, and justify why each effect you left in useLayoutEffect genuinely had to run before paint.
Own the tradeoff at codebase scale: measurement-driven positioning cannot run on the server, so it costs a post-hydration shift and browser-only tests. Decide when a CSS-expressible layout must replace measurement rather than letting every component reach for useLayoutEffect.
## What the user is actually seeing The tooltip is not "re-rendering twice" in a way that is visible by itself; re-renders are invisible. What is visible is a **painted frame**. The user sees a frame in which the tooltip is at its default coordinates, and then a later frame in which it is at the measured coordinates. Anything the browser paints, the user's eye can catch — one frame at 60 Hz is about 16 ms, which is plenty to register as a flicker when an element moves a hundred pixels. So the question is not "why did React render twice" but "why did the browser get to paint in between". ## The commit timeline React splits work into a render phase (calling your components, producing a description of the UI — no DOM is touched, and it can be thrown away or restarted) and a commit phase (applying that description to the real DOM). Within the commit, the order is fixed: 1. React mutates the DOM — nodes are inserted, attributes and text updated. 2. React runs `useLayoutEffect` cleanups and callbacks **synchronously**, bottom-up through the tree. The browser has not painted yet; it is still blocked in the same JavaScript task. 3. React returns control to the browser, which does style, layout, paint and composite — the frame the user sees. 4. After that paint, React flushes the passive effects — your `useEffect` callbacks. Measurement is only possible after step 1, because `getBoundingClientRect()` and `offsetWidth` read the real, laid-out DOM node; during render the node either does not exist yet or still holds its previous geometry. That leaves exactly two places to measure: step 2, before paint, or step 4, after paint. Choosing step 4 is choosing to show the user a frame you know is wrong. ## Why the layout-effect version has no visible intermediate state When you call `setState` inside a `useLayoutEffect`, React does not schedule that update for later. It re-renders and re-commits synchronously, still inside the same browser task, before the browser is allowed to paint. That is the entire reason the hook exists. The sequence collapses to: ```jsx useLayoutEffect(() => { const rect = anchorRef.current.getBoundingClientRect(); setPos({ top: rect.bottom + 8, left: rect.left }); }, [anchorRef]); ``` render (default position) → commit DOM → layout effect measures → setState → render again → commit DOM again → **paint once**, already correct. You still pay for two renders and two commits. You do not pay a visible frame. ## What it costs, and how to keep the cost small Everything inside a layout effect sits between the DOM mutation and the pixel. It is time added to a frame the browser is trying to finish, so it is directly responsible for jank if it is heavy. Practical discipline: - Measure once and write once. Interleaving reads and style writes in a loop forces the browser to recompute layout repeatedly. - Only put in `useLayoutEffect` the work whose result must be true *before* paint. A subscription, a logging call or a fetch has no visual consequence in the first frame and belongs in `useEffect`. - Guard the state write. If the computed position is equal to the current one, skipping `setState` avoids the extra render entirely; and an unguarded measure-write-measure cycle can loop. - Give the effect an honest dependency array. Measuring on every commit re-measures far more often than the geometry actually changes. ## Things that look like fixes but are not Wrapping the `setState` in `flushSync` inside a `useEffect` does not help: by the time a passive effect runs, the wrong frame is already on the user's screen. `requestAnimationFrame` inside a passive effect is also too late for the current frame. `setTimeout(..., 0)` is later still. None of them can retroactively unpaint a frame; only running before the paint can. ## Server rendering and the first paint `useLayoutEffect` cannot run during server rendering — there is no layout engine — and React warns about layout effects in that environment. The consequence is that a server-rendered page always ships the unmeasured markup and corrects itself after hydration. For a tooltip that is fine, because it is not visible until the user interacts. For something in the initial viewport, a measurement-driven layout means a guaranteed shift after hydration, and the right answer is usually to express the layout in CSS instead of measuring it. ## The rule to carry away If a user could see the intermediate state, measure and correct in `useLayoutEffect`. If they could not, use `useEffect` and keep the frame budget for the browser.
- Would wrapping the setState in flushSync inside the useEffect fix the flash just as well?No. `flushSync` forces React to render and commit that update synchronously, but a passive effect already runs after the browser painted, so the wrong frame is on screen before `flushSync` is even reached. It makes the correction land sooner within a later frame, not before the first one, and it opts out of batching. The timing problem is which effect you chose, not how the update is flushed.
- What happens to that useLayoutEffect when the component is server-rendered?It does not run — there is no DOM or layout engine on the server, and React warns that a layout effect does nothing there. The server sends the unmeasured markup, and the measurement happens after hydration on the client. That is acceptable for something not yet visible, like a tooltip, but for above-the-fold layout it guarantees a visible shift after hydration, so prefer a CSS solution there.
- Can you avoid the double render altogether and still position from a measurement?Not while the position is derived from real geometry: you cannot measure a node that has not been committed, so a measure pass after a first commit is inherent. You can reduce what it costs — render the element hidden with `visibility: hidden` so the first commit paints nothing visible, keep the measurement to one read, or express the relationship in CSS (flex, grid, `position: sticky`) so no measurement is needed at all.
saying these in an interview costs you the question
- useLayoutEffect is just useEffect with a longer name
- The flash is a React bug, not effect timing
- Put every effect in useLayoutEffect to be safe
- You can read the node's size during render
- setTimeout with zero delay removes the flash