React does not ship a built-in way to watch element visibility or size, so every team ends up writing the same useIntersectionObserver and useResizeObserver hooks — and most of the first attempts leak observers, double-fire in development, or break when the tracked element is swapped out mid-render. This guide covers the hook patterns that hold up under React 18 StrictMode, concurrent rendering, and SSR frameworks, building on the callback-scheduling model described in the IntersectionObserver API Deep Dive.

Concept Framing

A React hook wrapping a browser Observer API has to reconcile two lifecycles that were never designed to meet: React's declarative render cycle, which can run a component function any number of times before committing, and the browser's imperative Observer API, which expects observe() and disconnect() to be called exactly once per logical mount and unmount. Get the reconciliation wrong and you get one of three symptoms — duplicate callbacks, a stale reference to an unmounted element, or an observer that silently stops firing after a re-render swaps the ref's target.

The core design decision is where the observer instance lives. It must not live in component state (creating an observer during render is a side effect and will run twice under StrictMode's render-phase double-invocation) and it must not live in a plain module-level variable (that ties its lifetime to the module, not the component instance, and breaks the moment two instances of the hook run concurrently). The correct home is a useRef container populated inside useEffect, or the closure of a useEffect callback itself, since useEffect bodies run once per real commit and are exactly where the React team intends imperative browser APIs to be initialized.

The second decision is ref callback vs useRef. useRef gives you a stable .current container that React fills in during commit, but reading it only in useEffect misses any case where the underlying DOM node changes without a full unmount — for example, an element rendered conditionally inside the same component. A ref callback — a function passed to the ref prop instead of a ref object — is invoked by React with the node on attach and with null on detach, giving you an exact hook to call observe() and unobserve()/disconnect() at the right moments regardless of why the node changed.

The diagram below traces both the mount-time data flow and the two ref strategies side by side.

React Observer Hook Lifecycle — Mount, Observe, Callback, Unmount A vertical flow diagram with four stages: Render (JSX with ref prop), Commit (React attaches the DOM node), Effect Runs (observer created and observe() called), and Callback Fires (state updates trigger re-render). A branch shows Cleanup / Unmount calling disconnect(). A side panel compares useRef (read in effect) against ref callback (fires on attach and detach). Render (JSX + ref) Commit (node attaches) useEffect runs new Observer().observe(node) Callback fires setState(entry) → re-render Cleanup / Unmount observer.disconnect() repeats per threshold crossing Ref strategy comparison useRef Read ref.current inside useEffect after commit Best for a fixed element Ref callback Fires with node on attach, null on detach Best when the tracked node can change identity Both strategies must observe() only after the node exists in the DOM and disconnect() on every teardown path.

Spec / Signature Reference Table

The table below summarizes the shape of the two hooks this guide builds, matching the conventions used across the core observer fundamentals reference.

Hook Returns Key options Cleanup
useIntersectionObserver [ref, entry] or [ref, isIntersecting, ratio] threshold, rootMargin, root, once / freezeOnceVisible disconnect() in effect cleanup
useResizeObserver [ref, size] where size is { width, height } box (content-box / border-box / device-pixel-content-box) disconnect() in effect cleanup
useSharedObserver (advanced) observe(el, cb) / unobserve(el) pair Single shared instance keyed by options unobserve per caller, disconnect() at provider teardown

Step-by-Step Implementation

Step 1 — Pick the ref strategy

For a component that renders one element for its entire lifetime, useRef combined with useEffect is sufficient. For components where the observed node can be swapped — a carousel slide, a virtualized row, an element behind a conditional — use a ref callback so attach and detach events are never missed.

TypeScript
// useRef strategy — fixed element for the component's lifetime
const targetRef = useRef<HTMLDivElement>(null);

// Ref callback strategy — reacts to attach/detach directly
const [node, setNode] = useState<HTMLDivElement | null>(null);
const refCallback = useCallback((el: HTMLDivElement | null) => {
  setNode(el);
}, []);

Step 2 — Stabilize the callback with useCallback

The observer's callback function must not be recreated on every render, or the effect that owns the observer will re-run and tear down/recreate the native instance unnecessarily.

TypeScript
const handleIntersect = useCallback<IntersectionObserverCallback>((entries) => {
  const [entry] = entries;
  setEntry(entry);
}, []); // empty deps: setEntry's identity is stable, no external values captured

Step 3 — Create the observer inside useEffect

Instantiate the observer only once the target exists, guard against non-browser environments, and always return the cleanup function.

TypeScript
useEffect(() => {
  if (typeof IntersectionObserver === 'undefined') return; // SSR / unsupported guard
  if (!node) return;

  const observer = new IntersectionObserver(handleIntersect, {
    threshold: 0.1,
    rootMargin: '0px',
  });
  observer.observe(node);

  return () => observer.disconnect(); // runs on unmount AND before every re-run
}, [node, handleIntersect]);

Step 4 — Expose visibility state to consumers

Return the ref (or ref callback) alongside the derived state so the calling component can render conditionally.

TypeScript
function useIntersectionObserver(
  options: IntersectionObserverInit = {}
): [(el: Element | null) => void, IntersectionObserverEntry | undefined] {
  const [node, setNode] = useState<Element | null>(null);
  const [entry, setEntry] = useState<IntersectionObserverEntry>();

  const refCallback = useCallback((el: Element | null) => setNode(el), []);

  useEffect(() => {
    if (typeof IntersectionObserver === 'undefined' || !node) return;
    const observer = new IntersectionObserver(([e]) => setEntry(e), options);
    observer.observe(node);
    return () => observer.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [node, options.threshold, options.root, options.rootMargin]);

  return [refCallback, entry];
}

Step 5 — Build the equivalent useResizeObserver

TypeScript
function useResizeObserver(
  target: React.RefObject<Element>,
  box: ResizeObserverBoxOptions = 'content-box'
): { width: number; height: number } {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    if (typeof ResizeObserver === 'undefined' || !target.current) return;

    const observer = new ResizeObserver((entries) => {
      const entry = entries[0];
      const boxSize = box === 'border-box' ? entry.borderBoxSize[0] : entry.contentBoxSize[0];
      setSize({ width: boxSize.inlineSize, height: boxSize.blockSize });
    });

    observer.observe(target.current, { box });
    return () => observer.disconnect();
  }, [target, box]);

  return size;
}

The full generic, production-hardened version — with memoized options, an entry vs [isIntersecting, ratio] return-shape choice, and tests — is built step by step in Building a Reusable useIntersectionObserver Hook in TypeScript.

Configuration Variants

Variant Shape When to use
[ref, isIntersecting] Tuple, boolean only Simple show/hide toggles, entry animations
[ref, entry] Tuple, full IntersectionObserverEntry Need intersectionRatio, boundingClientRect, or time
{ ref, isIntersecting, ratio } Object, named fields Larger teams; object destructuring is more self-documenting at call sites
once / freezeOnceVisible: true Boolean option Lazy loading, one-shot entry animations — unobserves after first intersection
Shared observer registry observe/unobserve functions from context Hundreds of simultaneously mounted observed elements (long virtualized lists)

Choosing once: true internally calls unobserve() the first time entry.isIntersecting is true, which is the same terminal-state pattern used in the lazy image loader built for plain JavaScript — the hook is just a thin React wrapper around that same discipline.

Edge Cases & Gotchas

Options object identity breaks memoization. Passing { threshold: 0.5 } as a literal inline in JSX creates a new object every render, which — if used directly as a useEffect dependency — reruns the effect every render even though the values never changed. Destructure primitive option fields into the dependency array (options.threshold, options.rootMargin) instead of depending on the options object itself, or memoize the options object with useMemo.

useRef does not trigger re-renders, so effects can't react to node changes. If you use const ref = useRef<HTMLDivElement>(null) and read ref.current inside useEffect(() => {...}, []), the effect runs once at mount with whatever ref.current was at that time. If the underlying DOM node is swapped later (conditional rendering toggling between two elements sharing the same ref), the effect never re-runs and the observer keeps watching a detached node. Use the useState-backed ref callback pattern from Step 1 whenever the tracked node can change.

StrictMode double-invocation is not a bug in your code — it is a bug detector. In development, React 18 mounts, unmounts, and remounts every component tree once to verify effects are safely repeatable. If your cleanup function correctly calls disconnect(), this produces two observers total but only one survives after the synthetic remount completes — behavior identical to production. If you see two sets of live callbacks after the synthetic remount settles, the cleanup function is missing or incomplete. The dedicated guide on fixing doubled observer callbacks in React Strict Mode walks through the exact remount sequence.

Conditionally rendering the observed element unmounts the observer's target without unmounting the component. If your component wraps its child in {show && <div ref={refCallback}>}, toggling show to false calls the ref callback with null — your hook must treat null the same as an unmount for the previous node, disconnecting the old observer, and treat a later non-null call as a fresh mount.

useLayoutEffect vs useEffect for observer creation. Prefer useEffect for observer setup — it runs asynchronously after paint, matching the Observer API's own asynchronous callback delivery model. Reserve useLayoutEffect only for cases where you must read observer state synchronously before the browser paints (rare, and usually a sign that a useEffect is being used to defer a value that should be computed during render instead).

Framework Integration Patterns

Consuming the hook in a component

TypeScript
function LazySection({ children }: { children: React.ReactNode }): JSX.Element {
  const [refCallback, entry] = useIntersectionObserver({ threshold: 0.1 });
  const isVisible = entry?.isIntersecting ?? false;

  return (
    <div ref={refCallback} className={isVisible ? 'fade-in visible' : 'fade-in'}>
      {isVisible ? children : null}
    </div>
  );
}

Sharing one observer across a long list

When rendering hundreds of rows, creating one IntersectionObserver per row wastes native handles even though the browser batches callback delivery per observer. A shared-observer context wraps a single instance keyed by target element:

TypeScript
const ObserverContext = createContext<{
  observe: (el: Element, cb: (entry: IntersectionObserverEntry) => void) => void;
  unobserve: (el: Element) => void;
} | null>(null);

function ObserverProvider({ children }: { children: React.ReactNode }): JSX.Element {
  const callbacksRef = useRef(new Map<Element, (entry: IntersectionObserverEntry) => void>());
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    observerRef.current = new IntersectionObserver((entries) => {
      entries.forEach((entry) => callbacksRef.current.get(entry.target)?.(entry));
    }, { threshold: [0, 0.5, 1] });
    return () => observerRef.current?.disconnect();
  }, []);

  const observe = useCallback((el: Element, cb: (entry: IntersectionObserverEntry) => void) => {
    callbacksRef.current.set(el, cb);
    observerRef.current?.observe(el);
  }, []);

  const unobserve = useCallback((el: Element) => {
    callbacksRef.current.delete(el);
    observerRef.current?.unobserve(el);
  }, []);

  return (
    <ObserverContext.Provider value={{ observe, unobserve }}>
      {children}
    </ObserverContext.Provider>
  );
}

This is the same shared-instance principle recommended in the IntersectionObserver Deep Dive — one observer, many targets, one callback batch per frame — expressed as a React context provider instead of a module-level singleton.

SSR frameworks (Next.js, Remix)

Because useEffect never executes during server rendering, hooks that only create observers inside useEffect are already SSR-safe by construction. The remaining risk is hydration mismatch if the component's initial rendered output differs based on client-only visibility state — always render the same markup on the server and the first client render, then let the effect layer in the observer-driven update. The SSR & Hydration Observer Safety guide covers the hydration-mismatch failure mode in detail.

Debugging Checklist

Work through this list when a React observer hook misbehaves in the browser:

  • Two observers exist simultaneously in the DevTools memory profiler. Confirm the useEffect returns a cleanup function and that the dependency array does not omit the callback, causing silent effect skips.
  • The hook fires once at mount and never again. Check whether the ref was captured with useRef and read inside an effect with an empty dependency array — if the node changes later, the effect never reruns. Switch to the ref-callback pattern.
  • Callback receives stale props. Verify the callback passed to the observer is wrapped in useCallback with a complete dependency array, or reads current values from a ref rather than closing over a prop directly.
  • Hook works in production but appears to double-fire only in development. This is expected StrictMode behavior — verify by building a production bundle (next build && next start, or vite build && vite preview) and confirming the doubled callback disappears.
  • Observer never triggers for an element that is visually on screen. Check element.isConnected and confirm the ref callback actually received a non-null node — a common mistake is attaching the ref to a wrapper <React.Fragment> or a custom component that does not forward refs, in which case React never calls your ref function at all. Wrap non-forwarding custom components in forwardRef.

Frequently Asked Questions

When should I use a ref callback instead of useRef for observer hooks?

Use a ref callback whenever the observed node can change identity while the component stays mounted — conditional rendering, list reordering, or swapping which DOM element is tracked. A ref callback fires with the new node the instant React attaches or detaches it, so you can call observe() and disconnect() precisely at attach/detach time. useRef only gives you a stable container to read inside useEffect, which fires after render and cannot detect a swapped node without an extra dependency.

Why does my observer hook fire twice in development?

React 18 StrictMode intentionally mounts, unmounts, and remounts every component once in development to surface effects that are not idempotent. If your useEffect creates an observer and calls observe() without a guard, StrictMode produces two IntersectionObserver instances and two callback registrations for the same render. This does not happen in production builds. Fix it by returning a proper cleanup function that calls disconnect() so the first instance is fully torn down before the second mount runs.

Should I create one observer per hook instance or share one across the app?

For most component libraries, one observer per hook instance is simplest and correct — the browser already batches callback delivery per observer, so per-instance observers are not a performance problem at typical list sizes. Switch to a single shared observer registry, keyed by callback in a Map, only once you are rendering many hundreds of observed elements simultaneously and profiling shows observer instantiation overhead.

How do I avoid stale closures in the IntersectionObserver callback?

Wrap the callback in useCallback with an explicit dependency array, or store frequently changing values in a ref and read ref.current inside the callback instead of capturing the value directly. If the callback identity changes on every render because it closes over unstabilized props, the observer effect will re-run on every render, tearing down and recreating the native observer unnecessarily.

Do I need to guard useIntersectionObserver for server-side rendering?

Not inside useEffect — React never runs effect bodies during server rendering, so code inside useEffect is already client-only. You do need a typeof IntersectionObserver === 'undefined' guard if you read the constructor during render (for feature detection in JSX) or inside useLayoutEffect combined with certain streaming SSR setups where hydration timing is nonstandard.


↑ Back to Framework Integration & Observer Adapters