A React panel renders {open && <div ref={panelRef}>...</div>} and measures panelRef.current inside a useLayoutEffect with an empty dependency array. The height is missing the first time the panel opens and never updates when it is reopened. How would you make the measurement reliable?
answer
- nothing tells the effect a node appeared
- assigning to a ref schedules no work
- empty deps means the first commit only
- reopening creates a different DOM node
- let React hand you the node
basics
~20 sAn effect with an empty dependency array runs once after the first commit, when the conditional node does not exist yet, and assigning to a ref never schedules anything. Measure from a callback ref instead, which React invokes with the node exactly when it is attached.
solid answer
~50 sTwo things are working against that code. First, `panelRef.current` is set during commit, and mutating a ref never notifies React, so the effect has no way to learn that a node appeared. Second, `[]` means the effect runs after the first commit only — at that moment `open` is false and there is no node, so it measures `null` and never runs again. The narrow fix is to add `open` to the dependency array so the effect re-runs on each open and close. The better fix is a callback ref: React calls it with the node the moment it attaches and, in React 19, runs the cleanup it returns when the node detaches, so the measurement is tied to the node's real lifetime rather than the component's. The callback has to be stable — wrapped in `useCallback` — or React detaches and reattaches it on every render.
code
javascript · 18 linesimport { useCallback, useState } from 'react';
function Panel({ open, children }) {
const [height, setHeight] = useState(0);
const measureRef = useCallback((node) => {
if (!node) return;
const next = node.getBoundingClientRect().height;
setHeight((prev) => (prev === next ? prev : next));
}, []);
return (
<div>
<p>Measured height: {height}px</p>
{open && <div ref={measureRef}>{children}</div>}
</div>
);
}go deeper
Know that mutating a ref never triggers a render or an effect, and that an effect with an empty dependency array runs a single time after the first commit — so it cannot see a node that appears later.
Be able to explain both fixes and why they differ: adding the condition to the dependency array re-runs the effect around each open, while a callback ref fires on the node's own attachment and detachment.
Show the failure mode you have actually hit — a reopened panel producing a brand-new node, or an inline ref callback re-measuring every render — and reach for the callback ref as the general answer rather than patching dependency arrays case by case.
Push this into shared infrastructure: one reviewed measurement hook that takes ownership of attach, detach and resize, so individual features never hand-roll ref-plus-effect measurement and never rediscover the stale-node class of bug.
## Two separate bugs that look like one The symptom — "the measurement is missing, and it never recovers" — is produced by two independent facts about React, and fixing only one of them leaves a subtler version of the bug. **Refs do not notify.** A ref object is a plain mutable box. React sets `.current` during the commit, but assigning to it schedules no render and triggers no effect. Nothing in the system observes the transition from `null` to a node. **An empty dependency array means once.** `useLayoutEffect(fn, [])` runs after the first commit and never again until unmount. When the panel is closed on that first commit, the conditional subtree produced no DOM node, `panelRef.current` is `null`, and the measurement is of nothing. Later, when `open` flips to `true`, React commits a new `div` and sets `.current` — but the effect that would have measured it already ran and will not run again. Add the reopening case and it gets worse: closing and reopening creates a *different* DOM node each time, so even a value captured on the first open is stale geometry for a node that no longer exists. ## The narrow fix: honest dependencies If the effect's behaviour depends on whether the panel is open, `open` belongs in the dependency array: ```jsx useLayoutEffect(() => { const node = panelRef.current; if (!node) return; setHeight(node.getBoundingClientRect().height); }, [open]); ``` Now the effect re-runs on every open and close, sees the current node, and measures it before the browser paints. This works, and for a single boolean it is perfectly reasonable. Its weakness is that the dependency is a *proxy* for the thing you actually care about. You are asking "did the flag change" as a stand-in for "is there a node now, and is it the same node as before". As soon as the node's presence depends on several conditions, on a parent, or on a list position, the proxy drifts out of sync with reality and the bug returns in a form that is much harder to see. ## The general fix: measure in a callback ref A callback ref removes the proxy. Instead of passing a ref object, pass a function; React calls it with the DOM node when that node is attached, and when the node goes away it either calls the cleanup function your callback returned (React 19) or calls the callback again with `null`. The call happens during commit, before paint, which is exactly when a measurement is valid. ```jsx const measureRef = useCallback((node) => { if (!node) return; setHeight(node.getBoundingClientRect().height); }, []); return open && <div ref={measureRef}>...</div>; ``` This is correct for every case the dependency-array version handles by luck: first open, reopen with a fresh node, a node swapped for a different element type, a node that appears because a parent condition changed. The trigger is the node itself, not a variable you hope tracks the node. ## The stability trap React compares the ref callback by identity between renders. If it differs, React detaches the old one and attaches the new one — meaning an inline arrow function `ref={(node) => ...}` is torn down and re-run on **every** render. For a measurement callback that calls `setState`, that is a re-measure per render and, if the measurement feeds a value that affects the render, a loop. Wrap the callback in `useCallback` with a stable dependency list, or define it outside the component. This is also why a measurement callback should compare before it writes: `setHeight((prev) => (prev === next ? prev : next))` lets React bail out when nothing changed. ## When one measurement is still not enough A callback ref measures at attach time. It says nothing about what happens afterwards, when the panel's content grows or the viewport reflows. If the size can change while the node stays mounted, create a `ResizeObserver` inside the callback ref and disconnect it in the returned cleanup — the callback becomes the natural place to own the whole observation lifecycle, because it fires exactly when there is a node to observe and exactly when there is not. ## The rule to carry away When the thing you need to react to is *a DOM node existing*, let React tell you about the node. A callback ref is the only API that fires on node attachment; a ref object plus a dependency array is an approximation that holds only as long as your proxy variable happens to be accurate.
- Why is an inline arrow function as the ref a problem for a measuring callback?React compares ref callbacks by identity across renders, so a new inline function every render makes React detach the old ref and attach the new one each time. Your measurement then runs on every render rather than on attachment, and if the measured value feeds back into the render it can loop. Wrapping the callback in `useCallback` with stable dependencies pins the identity so it runs only when the node genuinely changes.
- How does the callback ref know when the node goes away?In React 19 a callback ref may return a cleanup function, and React runs that cleanup when the node detaches — so you can disconnect an observer or clear cached geometry there. If it returns nothing, React falls back to the older behaviour of calling the callback again with `null`. Either way the detachment is signalled at commit time, so teardown is tied to the node rather than to the component's unmount.
- You need the panel's height to keep updating while it stays open. What changes?Attach-time measurement alone is not enough, because it fires once per node. Create a `ResizeObserver` inside the callback ref, observe the node the callback was handed, update state from the entry sizes, and disconnect it in the cleanup the callback returns. The callback ref then owns the entire observation lifetime and cannot outlive or precede the node it is watching.
saying these in an interview costs you the question
- The ref is null so React must not have rendered it
- Adding a setTimeout gives the ref time to populate
- Changing a ref should re-run effects that read it
- Inline ref callbacks are equivalent to useCallback ones
- Measure in an empty-dependency effect, it always runs after mount