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(andx/y) — the offset of the content box from the element's border box. In practice: yourpadding-topandpadding-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:
useLatestwraps 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.useDeepCompareEffectguards the options object —useMeasure(ref, { box: 'border-box' })passes a fresh object literal every render. A plainuseEffectwith[options]would disconnect and reconnect the observer each time. Deep comparison means the observer only rebuilds when the options values actually change.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:
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:
-
useElementSizerespects theboxoption in the measured value. With{ box: 'border-box' }it reportsborderBoxSize— padding and border included — which is what you usually mean by "how big is this element".useMeasurealways reports the content box regardless of which box triggers observation, becausecontentRectis all ResizeObserver entries expose rect-wise. -
useElementBoundingis the only one that tracks position on scroll. It observes with ResizeObserver and listens to windowscroll/resize(passive), recomputinggetBoundingClientRect()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-measurereturns a callback ref;@reactuses/coreaccepts 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-measurereports viewport-relative bounds (with an optional scroll option);@reactuses/core'suseMeasurereportscontentRect. For viewport-relative-plus-scroll behavior, useuseElementBounding— that's the true equivalent. -
Debounce:
react-use-measuretakes adebounceoption. Here you compose instead: pipe the rect throughuseDebounceif you need to throttle downstream work. -
Stop switch: only
@reactuses/coregives youstop— 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
-
useMeasuregives you a live element rect with one line — ResizeObserver underneath, zero observer bookkeeping, automatic cleanup. -
It measures the content box, element-relative.
top/leftare padding offsets, not page position. For viewport coordinates and scroll tracking, useuseElementBounding; for border-box sizes,useElementSizewith{ box: 'border-box' }. -
The first render is all zeros — on the server and before the first observation. Guard
rect.width > 0where zero breaks your math. -
Inline callbacks and fresh options objects are safe —
useLatestanduseDeepCompareEffectinside prevent observer churn. -
stopends 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)