DEV Community

reactuse.com
reactuse.com

Posted on • Originally published at reactuse.com

React useMeasure Hook: Measure DOM Elements with ResizeObserver (2026)

Sooner or later every React app needs to know how big an element is. A chart needs its container's pixel width before it can draw. A virtualized list needs row heights. An auto-growing textarea, a truncation detector, a component that switches layout when it — not the viewport — gets narrow: all of them need live element dimensions, and React doesn't provide them. So you reach for getBoundingClientRect() in an effect, discover it only runs once, add a resize listener on window, and then notice the element also resizes when a sibling collapses, a font loads, or content changes — none of which fire a window resize event.

The correct primitive is ResizeObserver, and useMeasure from @reactuses/core is that primitive wrapped into one line: pass a ref, get back a live rect. No observer construction, no disconnect bookkeeping, no stale-closure traps. This post walks the API, the contentRect gotcha that trips almost everyone, how the hook works inside, how it compares to its sibling hooks (useElementSize, useElementBounding, useResizeObserver), and migration from react-use-measure. TypeScript-first.

The Simplest Case: A Live-Sized Container

import { useRef } from 'react';
import { useMeasure } from '@reactuses/core';

function Chart() {
  const ref = useRef<HTMLDivElement>(null);
  const [rect] = useMeasure(ref);

  return (
    <div ref={ref} style={{ width: '100%', height: '400px' }}>
      <svg width={rect.width} height={rect.height}>
        {/* draw with real pixel dimensions */}
      </svg>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's the whole pattern: a ref on the element, useMeasure(ref), and a rect that re-renders the component whenever the element's content box changes size — window resizes, flexbox reflows, sidebar toggles, font swaps, anything. You never touch ResizeObserver directly and there's no cleanup to remember; the observer disconnects automatically on unmount.

The Full API

const [rect, stop] = useMeasure(target, options?);
Enter fullscreen mode Exit fullscreen mode

target accepts the same flexible shapes as every element hook in @reactuses/core:

useMeasure(ref);                          // a React ref object
useMeasure(document.querySelector('#el')); // a raw Element
useMeasure(() => document.body);           // a function returning an element
Enter fullscreen mode Exit fullscreen mode

options is a standard ResizeObserverOptions object — { box: 'content-box' | 'border-box' | 'device-pixel-content-box' } — controlling which box triggers observations.

rect is a UseMeasureRect:

type UseMeasureRect = {
  x: number;
  y: number;
  width: number;
  height: number;
  top: number;
  left: number;
  bottom: number;
  right: number;
};
Enter fullscreen mode Exit fullscreen mode

Before the first observation fires (including during SSR), every field is 0.

stop is a function that disconnects the observer. Call it when you've measured what you needed and don't want further re-renders — say, after capturing an initial layout for an animation:

const [rect, stop] = useMeasure(ref);

useEffect(() => {
  if (rect.width > 0) {
    startEnterAnimation(rect);
    stop(); // one measurement was enough
  }
}, [rect, stop]);
Enter fullscreen mode Exit fullscreen mode

The contentRect Gotcha: top/left Are Not Viewport Coordinates

This is the number-one confusion with every ResizeObserver-based measure hook, so let's kill it early. The rect comes from entry.contentRect, and contentRect is relative to the element's own box, not the viewport:

  • width / height — the content box size: excludes padding, border, and scrollbars.
  • top / left (and x / y) — the offset of the content box from the element's border box. In practice: your padding-top and padding-left, not the element's position on the page.

So for an element with padding: 16px sitting 300px down the page, useMeasure reports top: 16, not top: 300. If what you actually want is where the element is on screen — for positioning a tooltip, a dropdown, a spotlight overlay — you want getBoundingClientRect() semantics, and that's a different hook: useElementBounding, which returns viewport-relative coordinates and also updates on scroll.

Rule of thumb: useMeasure answers "how big is it?"; useElementBounding answers "where is it?"

How It Works Inside

useMeasure is a thin layer over the library's useResizeObserver hook:

import { useState } from 'react';
import { useResizeObserver } from '../useResizeObserver';

export const useMeasure = (target, options = defaultOptions) => {
  const [rect, setRect] = useState(defaultState); // all zeros

  const stop = useResizeObserver(
    target,
    entries => {
      if (entries[0]) {
        const { x, y, width, height, top, left, bottom, right }
          = entries[0].contentRect;
        setRect({ x, y, width, height, top, left, bottom, right });
      }
    },
    options,
  );

  return [rect, stop] as const;
};
Enter fullscreen mode Exit fullscreen mode

The interesting machinery lives in useResizeObserver:

export const useResizeObserver = (target, callback, options) => {
  const savedCallback = useLatest(callback);
  const observerRef = useRef<ResizeObserver>();
  const { key: targetKey, ref: targetRef } = useStableTarget(target);

  const stop = useCallback(() => {
    observerRef.current?.disconnect();
  }, []);

  useDeepCompareEffect(() => {
    const element = getTargetElement(targetRef.current);
    if (!element) return;
    observerRef.current = new ResizeObserver(savedCallback.current);
    observerRef.current.observe(element, options);
    return stop;
  }, [targetKey, options]);

  return stop;
};
Enter fullscreen mode Exit fullscreen mode

Three details worth noticing:

  1. useLatest wraps the callback — you can pass an inline arrow function without tearing down and recreating the observer on every render. The observer is constructed once; the ref always points at the latest callback.

  2. useDeepCompareEffect guards the options objectuseMeasure(ref, { box: 'border-box' }) passes a fresh object literal every render. A plain useEffect with [options] would disconnect and reconnect the observer each time. Deep comparison means the observer only rebuilds when the options values actually change.

  3. The observer is created inside an effect — effects don't run on the server, so new ResizeObserver(...) never executes during SSR. The hook is SSR-safe by construction: the server renders with the all-zeros rect, the client hydrates identically, and the first observation fires after mount.

That third point also explains the initial { width: 0, height: 0 } render. Guard against it when zero would break your math:

const [rect] = useMeasure(ref);

return (
  <div ref={ref}>
    {rect.width > 0 && <Chart width={rect.width} height={rect.height} />}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

useMeasure vs useElementSize vs useElementBounding vs useResizeObserver

@reactuses/core ships four hooks in this space. They're layered, not redundant:

useMeasure useElementSize useElementBounding useResizeObserver
Returns [rect, stop] — full 8-field rect [width, height] { x, y, top, left, ... } viewport-relative stop (you get raw entries in a callback)
Source contentRect contentBoxSize / borderBoxSize getBoundingClientRect() Raw ResizeObserverEntry[]
Coordinates Element-relative (padding offsets) — (size only) Viewport-relative Whatever you read from entries
Updates on scroll No No Yes (window scroll + resize listeners) No
Box option Observation trigger only Measured value follows box Observation trigger only
Best for Size + a stop switch Just width/height, minimal re-renders Tooltips, popovers, overlays — positioning Custom logic; multiple elements; no state updates

Two distinctions deserve a sentence each:

  • useElementSize respects the box option in the measured value. With { box: 'border-box' } it reports borderBoxSize — padding and border included — which is what you usually mean by "how big is this element". useMeasure always reports the content box regardless of which box triggers observation, because contentRect is all ResizeObserver entries expose rect-wise.
  • useElementBounding is the only one that tracks position on scroll. It observes with ResizeObserver and listens to window scroll / resize (passive), recomputing getBoundingClientRect() on each. Heavier, but correct for anything anchored to screen position.

If you just need the viewport size, skip element observation entirely — that's useWindowSize.

Patterns

Container-Query-Style Responsive Component

Media queries respond to the viewport; components live in containers. A card in a wide main column and the same card in a narrow sidebar should lay out differently even on the same screen:

function ProfileCard() {
  const ref = useRef<HTMLDivElement>(null);
  const [rect] = useMeasure(ref);
  const compact = rect.width > 0 && rect.width < 320;

  return (
    <div ref={ref} className={compact ? 'card card--stacked' : 'card card--row'}>
      <Avatar />
      <Bio truncated={compact} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component adapts to the space it's given, wherever it's mounted. (CSS container queries cover the styling half of this; useMeasure covers the half where JavaScript needs the number — chart scales, virtualization math, conditional rendering.)

Canvas / SVG That Fills Its Parent

Canvas and SVG need explicit pixel dimensions. Bind them to the measured parent and redraw on change:

function Sparkline({ data }: { data: number[] }) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [rect] = useMeasure(wrapRef);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || rect.width === 0) return;
    canvas.width = rect.width * devicePixelRatio;
    canvas.height = rect.height * devicePixelRatio;
    drawSparkline(canvas, data);
  }, [rect, data]);

  return (
    <div ref={wrapRef} className="sparkline-wrap">
      <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every layout change — panel resize, sidebar collapse, orientation flip — re-renders the canvas at the correct resolution. No window.resize listener, which would miss the panel-resize and sidebar cases entirely.

Auto-Height Animation (Measure, Then Animate)

CSS can't transition height: auto. Measure the content, animate to the number:

function Collapsible({ open, children }: Props) {
  const innerRef = useRef<HTMLDivElement>(null);
  const [rect] = useMeasure(innerRef);

  return (
    <div
      style={{
        height: open ? rect.height : 0,
        overflow: 'hidden',
        transition: 'height 200ms ease',
      }}
    >
      <div ref={innerRef}>{children}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Because the measurement is live, the panel stays correct even if its content changes while open — an image finishes loading, a nested section expands. A one-shot getBoundingClientRect() snapshot would go stale the moment content shifted.

Truncation Detection

Show a "read more" affordance only when text actually overflows:

function Excerpt({ text }: { text: string }) {
  const ref = useRef<HTMLParagraphElement>(null);
  const [rect] = useMeasure(ref);
  const truncated =
    ref.current != null && ref.current.scrollHeight > Math.ceil(rect.height);

  return (
    <>
      <p ref={ref} className="clamp-3">{text}</p>
      {truncated && <button>Read more</button>}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

rect.height is the visible (clamped) content height, scrollHeight the full content height; comparing them detects overflow — and keeps detecting it as the container resizes, which is exactly when truncation state flips.

Coming from react-use-measure

If you've used pmndrs' react-use-measure, the mental model transfers directly — with a few differences:

// react-use-measure — hook creates the ref for you
const [ref, bounds] = useMeasure();
<div ref={ref} />

// @reactuses/core — you own the ref (or pass an element/function)
const ref = useRef<HTMLDivElement>(null);
const [rect, stop] = useMeasure(ref);
<div ref={ref} />
Enter fullscreen mode Exit fullscreen mode
  • Ref ownership: react-use-measure returns a callback ref; @reactuses/core accepts your ref, a raw element, or a getter function. Owning the ref means you can share it with other hooks (useClickOutside, useHover) on the same element without ref-merging utilities.
  • Coordinates: react-use-measure reports viewport-relative bounds (with an optional scroll option); @reactuses/core's useMeasure reports contentRect. For viewport-relative-plus-scroll behavior, use useElementBounding — that's the true equivalent.
  • Debounce: react-use-measure takes a debounce option. Here you compose instead: pipe the rect through useDebounce if you need to throttle downstream work.
  • Stop switch: only @reactuses/core gives you stop — a clean way to end observation after you've got what you came for.
  • One library, 100+ hooks: you're pulling from a full collection rather than adding a single-purpose dependency.

Takeaways

  • useMeasure gives you a live element rect with one line — ResizeObserver underneath, zero observer bookkeeping, automatic cleanup.
  • It measures the content box, element-relative. top/left are padding offsets, not page position. For viewport coordinates and scroll tracking, use useElementBounding; for border-box sizes, useElementSize with { box: 'border-box' }.
  • The first render is all zeros — on the server and before the first observation. Guard rect.width > 0 where zero breaks your math.
  • Inline callbacks and fresh options objects are safeuseLatest and useDeepCompareEffect inside prevent observer churn.
  • stop ends observation on demand — measure once for an animation, then stop paying for re-renders.
  • SSR-safe by construction — the observer is created in an effect, which never runs on the server.

Grab it from @reactuses/core and stop hand-rolling ResizeObserver wiring.

Top comments (0)