August 5, 2026

By ReactUse Team

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>
  );
}

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?);

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

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;
};

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]);

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;
};

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;
};

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>
);

useMeasure vs useElementSize vs useElementBounding vs useResizeObserver

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

useMeasureuseElementSizeuseElementBoundinguseResizeObserver
Returns[rect, stop] — full 8-field rect[width, height]{ x, y, top, left, ... } viewport-relativestop (you get raw entries in a callback)
SourcecontentRectcontentBoxSize / borderBoxSizegetBoundingClientRect()Raw ResizeObserverEntry[]
CoordinatesElement-relative (padding offsets)— (size only)Viewport-relativeWhatever you read from entries
Updates on scrollNoNoYes (window scroll + resize listeners)No
Box optionObservation trigger onlyMeasured value follows boxObservation trigger only
Best forSize + a stop switchJust width/height, minimal re-rendersTooltips, popovers, overlays — positioningCustom 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>
  );
}

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>
  );
}

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>
  );
}

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>}
    </>
  );
}

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} />
  • 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.