ResizeObserver in React: useResizeObserver Hook, Breakpoints, Responsive Logic
Build a production-ready useResizeObserver hook in React, wire up element-level breakpoints, and stop fighting window.resize hacks forever.
Why window.resize Was Always a Lie
You've been using window.resize and media queries for years. They work — mostly. But they only know about the viewport, not the element. A sidebar that collapses from 300px to 0px doesn't fire a media query. A card grid inside a resizable drawer doesn't trigger a breakpoint. The entire responsive model you're working in is viewport-centric, and modern layouts stopped being viewport-centric around 2019.
Enter ResizeObserver. It's a browser API — shipping since Chrome 64 (2018), Firefox 69, Safari 13.1 — that watches individual DOM elements and fires a callback whenever their dimensions change. No polling. No event delegation. No getBoundingClientRect inside a debounced scroll handler. Just a clean observer pattern hooked directly into the layout engine.
Honestly, the fact that the ecosystem took this long to standardize on ResizeObserver for element-level responsiveness is wild. The API has been stable for years. We just kept writing viewport hacks because that's what the tutorials showed.
Worth noting: ResizeObserver entries give you contentRect, borderBoxSize, and contentBoxSize. In practice you'll use contentRect.width and contentRect.height 90% of the time, but the box model variants matter when you're inside a CSS grid with padding in play.
Building a useResizeObserver Hook From Scratch
The hook is genuinely simple. You attach an observer to a ref, clean it up on unmount, and pipe the dimensions into state. Here's a production-ready version that handles the ref-callback pattern properly:
``tsx
import { useEffect, useRef, useState, useCallback } from 'react';
type Size = { width: number; height: number };
export function useResizeObserver<T extends HTMLElement>() {
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
const observerRef = useRef<ResizeObserver | null>(null);
const ref = useCallback((node: T | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
}
if (!node) return;
observerRef.current = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSize({ width, height });
});
observerRef.current.observe(node);
}, []);
useEffect(() => {
return () => observerRef.current?.disconnect();
}, []);
return { ref, size };
}
``
A few things worth calling out here. First, the ref is a callback ref, not a useRef. This is intentional — callback refs fire reliably when the element mounts or unmounts, whereas useRef with a useEffect dependency sometimes misses the initial render if the element is conditionally rendered. Second, we disconnect the old observer before attaching to a new node, which handles the case where your component re-mounts or the element swaps out underneath the hook.
One more thing — the useEffect cleanup is a belt-and-suspenders move. In most cases the callback ref's cleanup is enough, but if React ever batches the unmount in a way that skips the null callback, the effect cleanup catches it. Paranoid? Sure. But I've seen this bite people in Strict Mode double-invocations.
Quick aside: if you're on React 19 and using the new ref cleanup function syntax, you can drop the useEffect entirely and return a cleanup from the callback ref itself. That's cleaner, but the version above works on React 16.8+ which covers basically every production app you'll encounter.
Wiring Up Element-Level Breakpoints
Raw pixel values are fine, but usually what you want is semantic breakpoints: sm, md, lg — except scoped to the element, not the viewport. Here's how you layer that on top of the hook:
``tsx
type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const BREAKPOINTS: Record<Breakpoint, number> = {
xs: 0,
sm: 480,
md: 768,
lg: 1024,
xl: 1280,
};
function getBreakpoint(width: number): Breakpoint {
if (width >= 1280) return 'xl';
if (width >= 1024) return 'lg';
if (width >= 768) return 'md';
if (width >= 480) return 'sm';
return 'xs';
}
export function useElementBreakpoint<T extends HTMLElement>() {
const { ref, size } = useResizeObserver<T>();
const breakpoint = getBreakpoint(size.width);
const is = (bp: Breakpoint) => size.width >= BREAKPOINTS[bp];
return { ref, size, breakpoint, is };
}
``
Now in your component you get is('md') instead of comparing raw pixels, which reads way better in JSX:
``tsx
function AdaptiveCard() {
const { ref, breakpoint, is } = useElementBreakpoint<HTMLDivElement>();
return (
<div ref={ref} className="card">
<h2>{is('md') ? 'Full Title Here' : 'Short Title'}</h2>
{is('lg') && <Sidebar />}
<p>Current element breakpoint: {breakpoint}</p>
</div>
);
}
``
This is fundamentally different from CSS Container Queries (which we'll get to). The React layer lets you swap entire component trees, not just styles. That 48px icon on mobile versus a fully labelled nav on desktop — that's a component swap, not a CSS toggle, and ResizeObserver is the right tool for it.
In practice, I set my thresholds to match the container sizes that actually appear in my layout, not Tailwind's defaults. If your sidebar is always 320px–640px, your breakpoints should probably be 320, 480, 640 — not Tailwind's sm: 640px which assumes a full-width viewport.
Avoiding the setState-on-Every-Frame Trap
Here's the trap: ResizeObserver callbacks can fire at 60fps during a resize drag. If every callback triggers a React re-render, you'll get jank. The naive implementation above actually does this. For most components it's fine — React batches updates and the re-renders are cheap. But for complex subtrees or charts, you need to be smarter.
Option one: debounce the setState call. Simple, effective, slightly laggy.
``tsx
import { useMemo } from 'react';
function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// Inside the hook, replace the observer callback:
const handleResize = useMemo(() =>
debounce(([entry]: ResizeObserverEntry[]) => {
const { width, height } = entry.contentRect;
setSize({ width, height });
}, 100),
[]);
``
Option two — and honestly the better one for most cases — is to only update state when the breakpoint *changes*, not when the raw pixel value changes. If you're only consuming breakpoint and not size.width directly, you can filter the updates:
``tsx
const prevBreakpointRef = useRef<Breakpoint>('xs');
observerRef.current = new ResizeObserver(([entry]) => {
const { width } = entry.contentRect;
const next = getBreakpoint(width);
if (next !== prevBreakpointRef.current) {
prevBreakpointRef.current = next;
setBreakpoint(next);
}
});
``
That pattern gives you zero re-renders during a resize drag as long as you stay within the same breakpoint bucket. The re-render only happens when you cross a threshold. For a dashboard with 20 components that all depend on layout state, this is the difference between smooth and choppy.
One more thing — ResizeObserver callbacks run asynchronously after layout and paint, so they won't cause synchronous layout thrashing. That's already an advantage over getBoundingClientRect in a scroll listener. You're not making things worse by using this API; you're just deciding how often you want React to process the signal.
ResizeObserver vs CSS Container Queries
CSS Container Queries landed in all major browsers by 2023 and they solve a real subset of the same problem. With container-type: inline-size on a parent and @container rules in your CSS, you get element-scoped responsive styles without any JavaScript. That's genuinely great for pure styling changes — font sizes, gap values, column counts.
But CSS Container Queries can't swap component trees. They can't conditionally render a <MobileNav> vs a <DesktopNav>. They can't pass different props to a chart component. They don't give you a JavaScript variable you can feed to analytics or logging. The moment your responsive logic bleeds into component logic — which it does, constantly, in real apps — you need the JS layer.
Look, the right answer is to use both. Container Queries for CSS-level adjustments (they're faster and require zero JS), ResizeObserver for anything that needs to touch component logic. They're not competing tools; they're different layers of the same problem. If you're building glassmorphism components that need to adapt their blur radius and child structure based on element width, you'd use container queries for the blur and ResizeObserver for the structure.
That said, if you're targeting environments where Container Queries aren't available (some corporate intranet IE11 situation that we don't want to think about), ResizeObserver plus the breakpoint hook above covers 100% of the same surface area with a polyfill. The resize-observer-polyfill package on npm weighs in at about 3.5kb gzipped and works back to IE11.
Real-World Patterns: Charts, Tables, and Sidebars
Charts are the canonical ResizeObserver use case. Every charting library wants a pixel width to calculate scales. The old approach was imperatively calling .resize() on a debounced window.resize listener, which missed element-level changes entirely. With the hook:
``tsx
function ResponsiveLineChart({ data }: { data: DataPoint[] }) {
const { ref, size } = useResizeObserver<HTMLDivElement>();
return (
<div ref={ref} style={{ width: '100%', height: 320 }}>
{size.width > 0 && (
<LineChart
width={size.width}
height={320}
data={data}
/>
)}
</div>
);
}
``
The size.width > 0 guard is important. On the first render, before the observer fires, width is 0. Passing width={0} to Recharts or Victory will cause a visible flicker or a console error. Guard it and the chart appears cleanly on the first real measurement.
Tables with horizontal scroll are another killer use case. You want to show column actions inline when there's room, and collapse them into a row context menu when there isn't. That threshold isn't a viewport breakpoint — it's the table container's width. The hook makes this a five-line conditional.
Sidebars are similar. A resizable panel at 280px should probably show icon-only navigation. At 200px it might collapse to a rail. At 360px you've got room for labels and badges. These are element-level decisions. You can browse Empire UI templates that use exactly this pattern — the sidebar navigation adapts to its own container width, not the viewport, which means it works correctly when embedded in a split-pane layout.
One more pattern worth knowing: if you're using this hook in a Storybook story or a test environment where ResizeObserver isn't available, you'll get a runtime error. Mock it:
``ts
// in setupTests.ts or a Storybook decorator
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
``
Putting It All Together: a Drop-In Responsive Container
Here's a composable <ResponsiveContainer> that injects size and breakpoint as render props — useful when you want the responsive logic in one place and don't want to import the hook everywhere:
``tsx
type RenderProps = {
size: { width: number; height: number };
breakpoint: Breakpoint;
is: (bp: Breakpoint) => boolean;
};
interface ResponsiveContainerProps {
children: (props: RenderProps) => React.ReactNode;
className?: string;
}
export function ResponsiveContainer({
children,
className,
}: ResponsiveContainerProps) {
const { ref, size, breakpoint, is } = useElementBreakpoint<HTMLDivElement>();
return (
<div ref={ref} className={className}>
{children({ size, breakpoint, is })}
</div>
);
}
// Usage:
<ResponsiveContainer className="card-wrapper">
{({ is, breakpoint }) => (
<>
<h2>Current: {breakpoint}</h2>
{is('lg') ? <FullTable /> : <CompactList />}
</>
)}
</ResponsiveContainer>
``
This pattern composes well with any design system. If you're using the box shadow generator to craft elevation for your cards, the same card can drop its shadow complexity on small containers where the visual noise isn't worth it. The render prop gives you the signal; you decide what to do with it.
The useElementBreakpoint hook plus ResponsiveContainer is genuinely all you need for 95% of element-level responsive logic in a React app. Build these two things once, export them from a shared package or src/hooks, and stop reinventing the wheel on every feature team.
FAQ
Yes to both. ResizeObserver observes the element's layout in its own document context, so it works fine inside iframes — you just need to create the observer from within the iframe's window. Shadow DOM is also supported since the API works at the DOM node level, not the document level.
It can, yes. If your callback changes a style that alters the element's size, which fires the callback again, you're in a loop. The browser will warn you with a ResizeObserver loop limit exceeded error. Fix it by only mutating styles that don't affect the element's observed dimension, or by guarding the mutation with a size check before applying it.
One observer instance can watch multiple elements — just call observer.observe(el) for each. That's more efficient than creating a new ResizeObserver per element. In the hook above, each hook instance creates its own observer, which is fine for tens of components. If you're observing hundreds of elements, centralize into a single shared observer using a map of callbacks keyed by element.
Almost certainly not. ResizeObserver has had green coverage across Chrome, Firefox, Safari, and Edge since 2020. The only scenario where you'd need the resize-observer-polyfill package is targeting ancient WebViews in enterprise mobile apps or some embedded browser context. For standard web projects, you're safe to use it without a polyfill.