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 any useEffect that 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.

Size in State Versus Derived State Versus ImperativeThree approaches compared in two columns. Putting raw size in state re-renders every consumer every frame during a resize and draws a frame late. The recommended hook either derives a value such as a breakpoint and re-renders only when it changes, or calls an imperative handler in the observer callback that redraws in the same frame with no re-render.Raw size in useStateRe-render every frame of a resizeWhole subtree re-renders per consumerCanvas drawn in useEffect: a frame lateDerived or imperativeselect(): re-render only when the result changesonResize(): redraw in the observer callbackSame frame, no React work for pixels

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

TSX
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

TSX
// 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); }
  };
}
TSX
// 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;
}
TSX
// 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.

Re-Renders During a 2-Second Sidebar CollapseA bar chart of React re-renders across twenty cards during a two-second animated sidebar collapse. With raw size in state there were about two thousand four hundred re-renders. With a derived breakpoint selector there were about forty, two per card when crossing breakpoints. With the imperative mode for sparklines there were none.20 cards, animated sidebar collapse, 2 sraw size in state~2,400 rendersderived breakpoint~40 rendersimperative onResize0 renders

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.

StrictMode Mount Sequence With the HookFour steps in development StrictMode. The ref callback receives the element and subscribes it to the shared observer. StrictMode simulates an unmount: the cleanup unsubscribes and the element is unobserved. The ref callback receives the element again and subscribes again. The fresh observation delivers an initial entry, and the selector computes the same value, so no extra render happens.1Ref callback(el)subscribe → observe2Simulated unmountcleanup → unsubscribe → unobserve3Ref callback(el) againsubscribe → observe (fresh)4Initial entrySelector result unchanged: React bails out

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 ResizeObserver instance for the app.

Common Mistakes to Avoid

  • Raw size in state. Every pixel re-renders every consumer.
  • Drawing canvas in useEffect after 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.


↑ Back to React Observer Hooks