skip to content

A React sidebar measures its own width once with useLayoutEffect and stores it in state, but the sidebar is user-resizable and the stored width goes stale. How do you keep the measurement current, and what has to happen in the effect's cleanup?

level: middleimportance: should knowfreq 45%

answer

  1. a snapshot cannot track later changes
  2. subscribe to size instead of reading it
  3. the browser tells you when it changed
  4. teardown belongs in the effect cleanup
  5. guard the write or it can loop

basics

~20 s

Replace the one-shot read with a ResizeObserver created in the effect: observe the node from the ref, set state from each entry's size, and call observer.disconnect() in the cleanup so the observation stops when the component unmounts or the node changes.

solid answer

~50 s

A measurement taken once in a layout effect is a snapshot, and nothing re-runs it when the element itself changes size. I create a `ResizeObserver` inside the effect, `observe()` the node, and update state from the entry in the callback; the observer fires an initial observation as soon as you observe a node, so it replaces the manual first measurement too. The cleanup must call `observer.disconnect()` — otherwise the observer stays alive holding a reference to a detached node, and on a re-run you end up with two observers writing to the same state. I also make the update a no-op when the size has not actually changed, because a callback that unconditionally sets state on a component whose render affects its own size can loop. A `window` `resize` listener is not a substitute: it misses everything that resizes the element without resizing the viewport.

code

javascript · 21 lines
javascript
import { useCallback, useState } from 'react';

function useElementSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  const ref = useCallback((node) => {
    if (!node) return;
    const observer = new ResizeObserver(([entry]) => {
      const { inlineSize, blockSize } = entry.borderBoxSize[0];
      setSize((prev) =>
        prev.width === inlineSize && prev.height === blockSize
          ? prev
          : { width: inlineSize, height: blockSize }
      );
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, size];
}

go deeper

for a junior

Know that an element can change size without the window changing, and that anything you create in an effect — an observer, a listener, a timer — must be torn down in the function that effect returns.

for a middle

Be ready to write the observer wiring from memory: create it in the effect, observe the ref's node, read the entry's size, disconnect in the cleanup, and explain that observe() delivers an initial measurement.

for a senior

Show you have debugged this shape in production: guarding the state write so the component cannot feed its own resize, recognising a resize-loop error for what it is, and attaching through a callback ref so a swapped or conditionally rendered node cannot leave a stale observation.

for a principal

Decide where element size belongs at all. Push layout that CSS can express — flex, grid, container queries — out of JavaScript, and where measurement is genuinely required, standardise one reviewed hook rather than letting each component invent its own observer lifecycle.

## Why the one-shot measurement is wrong, not just incomplete `useLayoutEffect(() => { setWidth(el.offsetWidth); }, [])` answers the question "how wide was this element at the moment it first mounted". Everything else that changes an element's size leaves that answer untouched: the user dragging a splitter, a sibling in the same flex row growing, a web font finishing load and reflowing the text, content being added, the browser window zooming, a container query flipping a layout, an ancestor collapsing. The component keeps rendering against a number that stopped being true. The general fix is to stop treating size as a value you read and start treating it as a stream you subscribe to. ## ResizeObserver, and what it reports `ResizeObserver` is a browser API that notifies you when an observed element's box changes size, without you polling and without you forcing layout. You construct it with a callback, call `observe(node)` for each element you care about, and the callback receives an array of entries. Each entry carries the observed `target` and its measured geometry. `entry.contentRect` gives a rect for the content box; `entry.borderBoxSize` and `entry.contentBoxSize` are arrays of size objects with `inlineSize` and `blockSize` (writing-mode aware — for normal horizontal text, inline is width and block is height). Reading these is cheap because the browser measured them for you; you are not calling `getBoundingClientRect()` and forcing a layout recalculation. Two behaviours matter for React code: - Calling `observe()` delivers an initial observation with the element's current size. That is why the observer can replace your first manual measurement instead of supplementing it. - The callback is delivered by the browser at a point where you may safely read layout, and if your callback resizes the observed elements, the browser can report a resize-loop error rather than spinning forever. Treat that error as a signal that your callback writes something that feeds back into the size it measures. ## Wiring it into a component correctly Three things have to line up: the observer must be created where you have a node, torn down when you lose it, and it must not fight your own renders. ```jsx useEffect(() => { const node = ref.current; if (!node) return; const observer = new ResizeObserver(([entry]) => { setWidth(entry.contentRect.width); }); observer.observe(node); return () => observer.disconnect(); }, []); ``` The cleanup is not optional bookkeeping. Without `disconnect()`, the observer object stays registered with the browser, keeps a strong reference to a node that is no longer in the document, and keeps invoking a callback that calls `setState` on an unmounted component. If the effect re-runs for any reason you now have two live observers, both writing to the same state, and in development StrictMode deliberately mounts, unmounts and remounts your component so a missing cleanup shows up immediately as a doubled observer. Whether you can use `useEffect` or need `useLayoutEffect` depends on what you do with the number. If the size only feeds a later visual decision, a passive effect is fine and keeps the work off the critical path. If the first painted frame would visibly be wrong without it, measure in a layout effect. ## Guarding the state write A size-driven component is a feedback loop waiting to happen: state changes → render changes layout → element resizes → callback fires → state changes. Even when there is no true loop, writing a new object on every notification re-renders on every scroll-driven sub-pixel change. Compare before you write: ```jsx setSize((prev) => prev.width === next.width && prev.height === next.height ? prev : next ); ``` Returning the previous state object lets React bail out of the re-render entirely. ## Why a window resize listener is not the same thing `window.addEventListener('resize', ...)` fires when the viewport changes. It does not fire when a splitter is dragged, when a sibling grows, when text reflows after a font loads, when content is inserted, or when an ancestor's `display` changes. It also fires for elements whose size did not change, so you pay for measurements you did not need. The observer answers the question you actually asked: did *this element* change size. ## Attaching to the right node An effect with `[]` captures whatever node exists at first commit. If the observed element is conditionally rendered or swapped for a different element, the observer keeps pointing at a stale node. Wiring the observer through a callback ref instead — creating it when React hands you the node and disconnecting when it hands you `null` or runs the returned cleanup in React 19 — makes attachment follow the node's real lifetime rather than the component's. ## The same shape applies to IntersectionObserver Visibility works identically: construct an `IntersectionObserver` in an effect against the ref's node, read `entry.isIntersecting` in the callback, and `disconnect()` in the cleanup. It is the same subscribe-and-tear-down discipline applied to a different measurement.

  • What actually breaks if you forget the disconnect() in the cleanup?
    The observer stays registered with the browser and keeps a live reference to a node that is no longer in the document, so it leaks the node and its subtree. Its callback also keeps calling your state setter, and if the effect ever re-runs you accumulate a second observer writing to the same state. In development, StrictMode's extra mount/unmount cycle surfaces the doubled observer straight away.
  • Why does a ResizeObserver callback sometimes report a resize-loop error in the console?
    Because the callback changed something that resizes the observed element, so the new size triggers another notification. The browser detects that undelivered notifications remain after a pass and reports a loop error instead of spinning. The fix is to stop writing back into the measured dimension — guard the state update so an unchanged size writes nothing, or drive the layout from a value that the callback does not influence.
  • When would you still use window's resize event rather than a ResizeObserver?
    When the thing you care about really is the viewport — deciding a breakpoint mode, repositioning something fixed to the window, or recomputing a value derived from `window.innerWidth`. For any question of the form "how big is this element now", the observer is both more accurate and cheaper, because it fires exactly for that element and hands you a measurement instead of making you force one.

saying these in an interview costs you the question

  • Measure once on mount, size will not change
  • A window resize listener covers every case
  • Cleanup is optional since the component unmounts anyway
  • ResizeObserver polls the element on a timer
  • Call getBoundingClientRect on every render instead

context