August 13, 2026
React useElementSize Hook: Track Element Width & Height with ResizeObserver (2026)
Media queries answer one question: how big is the viewport? But your components don’t live in the viewport — they live in columns, cards, panels, and grid tracks. The same <ProductCard> is 900px wide in a full-width main column and 320px wide next to an open sidebar, on the same screen. And an element’s size changes for a dozen reasons that never fire a window resize event: a sidebar collapses, an accordion expands, a font finishes loading, a flex sibling appears, content streams in.
Tracking any of that means ResizeObserver — the browser API built for exactly this — wrapped in the usual React ceremony of refs, effects, and cleanup. useElementSize from @reactuses/core reduces the whole thing to two numbers your component just renders: [width, height], live, for any element.
Quick Start
npm install @reactuses/core
import { useRef } from "react";
import { useElementSize } from "@reactuses/core";
function ChartCard() {
const ref = useRef<HTMLDivElement>(null);
const [width, height] = useElementSize(ref);
return (
<div ref={ref} className="chart-card">
<Chart width={width} height={height} />
</div>
);
}
That’s the entire integration. The hook attaches a ResizeObserver on mount, and because ResizeObserver reports once immediately on observe(), width and height populate right after first paint without a separate “measure on mount” pass. Every subsequent size change — window resize, sidebar toggle, content reflow — updates the state. Unmount cleans up the observer. No refs to observers, no disconnect() to forget.
Why Not window.innerWidth or a Media Query?
Because most element resizes have nothing to do with the window:
- A collapsible sidebar opens and your main column loses 280px — viewport unchanged.
- A user drags a split-pane divider.
- An
<img>above your element finishes loading and pushes everything down and reflows the column. - A filter empties a flex row and the survivors stretch.
- A CSS transition animates a panel’s width over 300ms.
Window-level tools are blind to all of it. useWindowSize and useMediaQuery are the right calls for page-level layout decisions — but a component that keys its layout on viewport width breaks the first time someone renders it in a narrow column on a wide screen.
CSS container queries deserve a mention here: if your response to size is pure styling, @container handles it with zero JavaScript and zero re-renders — use that. The hook earns its place the moment you need the number in JS: chart dimensions, canvas backing stores, virtualization math, or rendering a genuinely different component tree.
The Manual Way — and Where It Bites
Hand-rolling looks short enough:
// ⚠️ hand-rolled — works in the demo, leaks bugs in the app
function ChartCard() {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
ro.observe(ref.current);
return () => ro.disconnect();
}, []);
return <div ref={ref}>…</div>;
}
Three problems hiding in ten lines:
- Late-mounting targets never get observed. That
if (!ref.current) returnguard runs once, on mount. If the element renders conditionally — behind a loading state, a tab, a modal — the effect already returned and nothing ever attaches. You need the effect to re-run when the element appears, which an empty dependency array can’t express. - The options-identity trap. Want
{ box: "border-box" }? That object literal is new every render. Put it in the effect’s dependency array and you tear down and recreate the observer on every render; leave it out and the lint rule yells or a later edit silently stales it. - You read the wrong box.
contentRectis the legacy field, kept for compatibility. The modern fields —borderBoxSize,contentBoxSize,devicePixelContentBoxSize— are arrays (elements can fragment across columns), and picking and summing the right one is more code than the observer itself.
useElementSize absorbs all three: for conditionally rendered elements you pass a lazy getter (() => document.querySelector(".panel")) and the hook re-resolves it every render, attaching the moment the element exists; options are deep-compared (inline literals are fine); and box selection — including fragment summation — is handled per spec.
The useElementSize API
const [width, height] = useElementSize(target, options?);
target is flexible — pass whichever you have:
useElementSize(ref); // a ref object
useElementSize(document.getElementById("hero")); // an element
useElementSize(() => document.querySelector(".panel")); // a lazy getter
options is a standard ResizeObserverOptions — one field, box, three values. And because the hook deep-compares options internally, useElementSize(ref, { box: "border-box" }) with an inline literal does not churn the observer every render.
Which box should you measure?
content-box(default) — the content area only: padding and border excluded. This is “how much room does my content have,” the right box for laying out children, charts, and column math.border-box— padding and border included; matchesoffsetWidth/offsetHeightand how much space the element occupies in layout. Reach for it when coordinating with siblings or overlays.device-pixel-content-box— the content box in physical device pixels. This one is special.
The crisp-canvas trick
A <canvas> whose backing store doesn’t match its physical pixel size renders blurry on 2× displays. The folk fix — multiply CSS pixels by devicePixelRatio — rounds wrong under browser zoom and fractional DPR. device-pixel-content-box hands you the exact integer the compositor uses:
function SharpCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
const [width, height] = useElementSize(ref, {
box: "device-pixel-content-box",
});
useEffect(() => {
const canvas = ref.current;
if (!canvas || !width) return;
canvas.width = width; // physical pixels — pixel-perfect at any DPR or zoom
canvas.height = height;
draw(canvas.getContext("2d")!);
}, [width, height]);
return <canvas ref={ref} style={{ width: "100%", height: 300 }} />;
}
(Safari doesn’t support device-pixel-content-box yet — the hook falls back to contentRect there, so degrade gracefully rather than assuming physical pixels everywhere.)
Real Patterns
Container-query components — breakpoints on the element, not the screen
function ProductCard() {
const ref = useRef<HTMLDivElement>(null);
const [width] = useElementSize(ref);
const layout =
width >= 640 ? "horizontal" : width >= 320 ? "compact" : "stacked";
return (
<article ref={ref} data-layout={layout}>
{layout === "horizontal" ? <SideBySide /> : <Stacked />}
</article>
);
}
Drop this card in a sidebar, a modal, or a full-width list and it adapts to its own space — no prop-drilling a variant from whoever happens to know the context. Again: if the difference were only CSS, @container does this cheaper. This pattern is for when the component tree changes.
Responsive charts that re-layout at rest
Chart libraries want pixel numbers, and re-computing a chart layout 60 times a second while the user drags a splitter is wasted work. Let CSS stretch the canvas visually and settle the real re-layout with useDebounce:
const ref = useRef<HTMLDivElement>(null);
const [rawWidth, rawHeight] = useElementSize(ref);
const width = useDebounce(rawWidth, 150);
const height = useDebounce(rawHeight, 150);
// <ExpensiveChart width={width} height={height} /> re-lays-out
// once the drag stops, not on every frame of it.
How many columns fit?
Grid math that CSS can’t do — because the answer feeds props, not styles:
const [width] = useElementSize(ref);
const columns = Math.max(1, Math.floor(width / 280));
return <VirtualGrid columns={columns} items={items} />;
useElementSize vs Its Siblings
@reactuses/core ships a small family of measurement hooks built on the same observer core — pick by what you need back:
| Hook | Returns | Reach for it when |
|---|---|---|
useElementSize | [width, height] | you need dimensions, nothing else |
useMeasure | full contentRect (x/y/top/left/…) + a stop() | you want the whole rect, or to stop observing on demand |
useElementBounding | live getBoundingClientRect — updates on scroll and resize | you need where it is in the viewport, not just how big |
useResizeObserver | your callback gets raw entries | side effects instead of state; imperative work per resize |
useWindowSize | viewport width/height | page-level layout, not element-level |
The rule of thumb: useElementSize for dimensions, useElementBounding for position (tooltips, popovers, scroll-linked effects), useResizeObserver when you’d rather run code than store state.
Production Notes
- SSR is handled. There’s no DOM on the server, so the hook renders
[0, 0]and attaches the observer after hydration — notypeof windowguards in your code. Plan for the zero-frame: gate expensive children withif (!width) return <Skeleton />rather than letting a chart lay out at 0×0. - The first real value arrives via the observer’s initial report — one extra render right after mount. That’s the cost of correctness; don’t fight it.
- Beware self-referential loops. If the content you render from the measured width changes the element’s own width, you’ve built a resize feedback loop (
ResizeObserver loop completed with undelivered notificationsin the console). Fix it by measuring a parent whose size you don’t alter, or clamping the derived values. - Fragmented layouts are summed correctly. In multi-column or paginated contexts an element’s box can fragment; the hook sums
inlineSize/blockSizeacross fragments per spec instead of reading only the first one. - Every hook call is one observer. Measuring 500 virtualized rows individually means 500 observers — at that scale, drop down to a single
useResizeObserveron the container, or observe one prototype row.
Takeaways
- Element size ≠ window size. Sidebars, split panes, streaming content, and font loads all resize elements without touching the viewport — only a
ResizeObserversees them, anduseElementSizeserves it as plain[width, height]state. - The
boxoption picks what you measure:content-boxfor content math (default),border-boxfor layout footprint,device-pixel-content-boxfor pixel-perfect canvases at any DPR or zoom. - Targets can be refs, elements, or lazy getters — use a getter for conditionally rendered elements; inline options don’t churn the observer thanks to deep comparison — the bugs every hand-rolled version has, pre-fixed.
- Pure-CSS response to size? Use
@containerqueries. The hook is for when the number drives JavaScript: charts, canvas, virtualization, or swapping component trees. - Need position too? That’s
useElementBounding. The full rect plus manual stop?useMeasure. Raw entries?useResizeObserver.
useElementSize and 110+ other SSR-safe, TypeScript-first hooks live in @reactuses/core — one install, tree-shakeable, no dependencies to babysit.
npm install @reactuses/core