A good useResizeObserver hook attaches through a callback ref, subscribes the element to one shared ResizeObserver, and offers two modes: a derived-state mode that re-renders only when a selector's result changes (for breakpoints), and an imperative mode that calls a handler without touching React state (for canvas and charts that must redraw every frame).
Problem / Scenario Context
A React dashboard has a useSize() hook that stores { width, height } in useState from a ResizeObserver callback. Components use it to pick layouts and to size charts. During an animated sidebar collapse, every component using the hook re-renders every frame — dozens of components, each re-rendering its whole subtree for a width that changed by a few pixels — and the animation stutters. A chart that redraws its canvas in a useEffect after the state update is also a frame behind the container.
The problem is not ResizeObserver; it is routing every size change through React state. The React Observer Hooks topic covers hook fundamentals and why callback refs beat useEffect for observer targets; this page builds the resize hook.
Mechanics Explanation
A ResizeObserver callback runs during the rendering steps, after layout, before paint. What happens next depends on how the hook uses the value:
setState(size)on every entry schedules a React render. React batches and renders after the current task; the commit and anyuseEffectthat draws land in a later frame. Every component using the hook re-renders for every size change.setState(select(size))only when the selected value changes re-renders only at meaningful thresholds — breakpoints, column counts — which is usually a handful of times per drag.- Imperative handler — calling a function with the entry directly from the observer callback, without React state — lets canvas and chart code redraw in the same frame as the resize, with zero React work.
Callback refs matter because a component's observed element can change (conditional rendering, keyed lists), and a ref callback is called with every new element and with null on removal — the right moments to subscribe and unsubscribe. React 19 lets ref callbacks return a cleanup function, which makes this concise.
Comparison Table: Hook Modes
| Mode | Re-renders during a drag | Timing | Use for |
|---|---|---|---|
| Raw size in state | every frame | a frame late | small, cheap components only |
Derived state (select) |
only on change (e.g. 3 breakpoints) | a frame late | layout variants, column counts |
Imperative (onResize) |
none | same frame | canvas, charts, virtual lists |
| CSS container queries | none | same frame | pure styling |
Minimal Reproducible Example
function useSize<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const ro = new ResizeObserver(([e]) => setSize({ width: e.contentRect.width, height: e.contentRect.height }));
ro.observe(ref.current!);
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
One observer per component, a re-render per pixel, and a stale observation if ref.current changes to a different element after mount.
Production-Safe Solution
// shared-resize.ts — one ResizeObserver for the whole app
type Handler = (e: ResizeObserverEntry) => void;
const handlers = new Map<Element, Set<Handler>>();
let ro: ResizeObserver | null = null;
function subscribe(el: Element, h: Handler, box: ResizeObserverBoxOptions = 'content-box'): () => void {
ro ??= new ResizeObserver((entries) => {
for (const e of entries) handlers.get(e.target)?.forEach((fn) => fn(e));
});
let set = handlers.get(el);
if (!set) { set = new Set(); handlers.set(el, set); ro.observe(el, { box }); }
set.add(h);
return () => {
set!.delete(h);
if (set!.size === 0) { handlers.delete(el); ro!.unobserve(el); }
};
}
// useResizeObserver.ts
import { useCallback, useRef, useState } from 'react';
interface Options<S> {
select?: (e: ResizeObserverEntry) => S; // derived state mode
onResize?: (e: ResizeObserverEntry) => void; // imperative mode
box?: ResizeObserverBoxOptions;
}
export function useResizeObserver<T extends Element, S = undefined>({ select, onResize, box }: Options<S>) {
const [selected, setSelected] = useState<S | undefined>(undefined);
// Keep the latest callbacks without re-subscribing when they change identity.
const latest = useRef({ select, onResize });
latest.current = { select, onResize };
const ref = useCallback((el: T | null) => {
if (!el) return;
const unsubscribe = subscribe(el, (entry) => {
latest.current.onResize?.(entry); // same frame, no React state
const sel = latest.current.select;
if (sel) {
const next = sel(entry);
setSelected((prev) => (Object.is(prev, next) ? prev : next)); // bail out if unchanged
}
}, box);
return unsubscribe; // React 19 ref cleanup
}, [box]);
return [ref, selected] as const;
}
// Derived state: re-render only when the breakpoint changes.
function Card() {
const [ref, bp] = useResizeObserver<HTMLDivElement, 'sm' | 'md' | 'lg'>({
select: (e) => { const w = e.contentBoxSize[0].inlineSize; return w < 360 ? 'sm' : w < 720 ? 'md' : 'lg'; },
});
return <div ref={ref} data-bp={bp}>{bp === 'sm' ? <Compact /> : <Full />}</div>;
}
// Imperative: redraw a canvas in the same frame, never re-render.
function Sparkline({ data }: { data: number[] }) {
const canvas = useRef<HTMLCanvasElement>(null);
const [ref] = useResizeObserver<HTMLDivElement>({
onResize: (e) => draw(canvas.current!, data, e.contentBoxSize[0].inlineSize),
});
return <div ref={ref} className="spark"><canvas ref={canvas} /></div>;
}
declare function Compact(): JSX.Element; declare function Full(): JSX.Element;
declare function draw(c: HTMLCanvasElement, d: number[], w: number): void;
setSelected with an Object.is check returns the previous value when nothing changed, and React bails out of the render entirely. The latest ref lets consumers pass inline functions without causing re-subscription. For React 18, where ref callbacks cannot return cleanups, store the unsubscribe in a ref and call it when the callback receives null.
StrictMode and Concurrent Rendering
React 18+ StrictMode mounts, unmounts and remounts components in development, and calls ref callbacks with null and then the element again. With the shared service, that becomes subscribe → unsubscribe → subscribe for the same element: the element is observed, unobserved and observed again, and the re-observation produces a fresh initial entry, which is harmless. The pattern never creates duplicate subscriptions because each subscription is cleaned up before the next — the discipline covered in fixing doubled observer callbacks in React Strict Mode.
Concurrent rendering does not affect the hook: observer callbacks run outside React, and setSelected is an ordinary state update. For derived values that feed expensive subtrees, wrapping the update in startTransition lets React interrupt the re-render for user input.
Verification Steps
- Count renders with React DevTools Profiler during a container resize; expect renders only at breakpoint changes.
- Resize a canvas-based component and confirm it redraws in the same frame (no one-frame lag in a trace).
- Swap the observed element (conditional render) and confirm the new element is observed and the old one released.
- Run in StrictMode and confirm no duplicate callbacks after mount settles.
- Heap snapshot: one
ResizeObserverinstance for the app.
Common Mistakes to Avoid
- Raw size in state. Every pixel re-renders every consumer.
- Drawing canvas in
useEffectafter a state update. It lands a frame late; draw in the observer callback. useRef+useEffect([])for the target. Misses element changes; use a callback ref.- Recreating the observer when callbacks change identity. Keep latest callbacks in a ref.
FAQ
Why a callback ref instead of useRef and useEffect?
A callback ref is called whenever the underlying element changes, including when a conditional render swaps it, and with null on removal. useEffect with an empty dependency array observes only the element present at mount.
How does the hook avoid re-rendering on every pixel?
In select mode it stores only the selector's result and passes a functional update that returns the previous value when nothing changed, so React bails out. In onResize mode it does not touch React state at all.
Is it safe to draw a canvas inside the ResizeObserver callback?
Yes, and it is the best place: layout is fresh and paint has not happened, so the drawing and the new size appear together. Setting the canvas's width attribute does not change its CSS box, so it does not trigger another resize entry.
What about server-side rendering?
Ref callbacks do not run on the server, so nothing touches ResizeObserver there. The selected value is undefined until the first entry; render a sensible default for that case to avoid a hydration mismatch.
Should I use useSyncExternalStore for the size?
It fits global stores shared by many components, such as the viewport. For per-element sizes, the callback-ref pattern with local state is simpler and avoids subscribing every consumer to every element's changes.
How does this compare with CSS container queries?
If the size only changes styles, container queries are better: no JavaScript and no re-render at all. Use the hook when size changes what renders or needs to drive imperative drawing.
Related
- Building a TypeScript useIntersectionObserver Hook — the visibility counterpart
- Sharing One Observer Across React Components with Context — scoping the shared instance
- Building a Shared ResizeObserver Service — the service behind the hook
↑ Back to React Observer Hooks