DEV Community

ReactUse
ReactUse

Posted on • Originally published at reactuse.com

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
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

Three problems hiding in ten lines:

  1. Late-mounting targets never get observed. That if (!ref.current) return guard 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.
  2. 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.
  3. You read the wrong box. contentRect is 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?);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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; matches offsetWidth/offsetHeight and 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 }} />;
}
Enter fullscreen mode Exit fullscreen mode

(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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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} />;
Enter fullscreen mode Exit fullscreen mode

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 — no typeof window guards in your code. Plan for the zero-frame: gate expensive children with if (!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 notifications in 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/blockSize across 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 useResizeObserver on 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 ResizeObserver sees them, and useElementSize serves it as plain [width, height] state.
  • The box option picks what you measure: content-box for content math (default), border-box for layout footprint, device-pixel-content-box for 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 @container queries. 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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)