A production-ready useIntersectionObserver hook is a generic function that accepts typed options, tracks its target through a ref-callback-backed state variable rather than a bare useRef, memoizes its options to keep the effect dependency array stable, and always disconnects on cleanup — this page builds that hook field by field.

One-Sentence Answer

Type the hook generically over Element, back the ref with useState instead of useRef so attach/detach transitions are observable, memoize the options object, and return disconnect() from the effect's cleanup function.

Problem / Scenario

Most first-draft useIntersectionObserver hooks work fine in a demo and then fail in three specific ways once they hit a real codebase: they lose type safety the moment a caller needs an HTMLImageElement instead of a generic HTMLDivElement; they silently stop observing after a conditional re-render swaps the tracked node; and they thrash — disconnecting and reconnecting the observer on every render — because the options object passed in JSX ({ threshold: [0, 0.5, 1] }) is a fresh object literal every time and ends up in the effect's dependency array. The underlying option semantics — what threshold, root, and rootMargin actually control — are covered in full in the IntersectionObserver API Deep Dive; this guide assumes that reference and focuses purely on the TypeScript and React wiring around it.

This guide builds the hook incrementally, fixing each of those three problems in order, and lands on the same shape referenced from the parent React Observer Hooks guide, which also covers the equivalent useResizeObserver and StrictMode implications in more general terms.

Mechanics

The hook has four moving parts that must stay correctly synchronized:

  1. Generic element type parameter — lets TypeScript infer the correct DOM element interface for ref.current without a manual cast at the call site.
  2. A useState-backed ref callback — unlike useRef, updating state triggers a re-render, which lets a useEffect depending on that state value re-run precisely when the node attaches or detaches.
  3. A memoized options objectIntersectionObserverInit fields (threshold, root, rootMargin) are primitives or arrays; arrays are reference types, so a threshold: [0, 1] literal must either be defined outside the render function or wrapped in useMemo.
  4. A terminal-state guard for once/freezeOnceVisible — once the target has intersected, further callback firings are unnecessary for one-shot use cases like lazy image loading (the same terminal-state discipline used in the lazy image loader built with IntersectionObserver) — call unobserve() the instant isIntersecting becomes true and freeze the returned state so the UI does not flicker if the element leaves and re-enters the viewport. Skipping this guard is also the most common cause of the doubled-callback symptom covered in fixing doubled observer callbacks in React Strict Mode, since a hook that never disconnects keeps every stale observer alive across remounts.

The generic parameter deserves a closer look because it is the part most implementations get wrong. Declaring function useIntersectionObserver<T extends Element = HTMLDivElement>(...) constrains T to anything that extends the DOM Element interface while defaulting to HTMLDivElement for callers who do not specify a type argument. This means useIntersectionObserver() with no type argument returns a ref callback typed (el: HTMLDivElement | null) => void, matching the common case of tracking a wrapper <div>, while useIntersectionObserver<HTMLImageElement>() narrows the same ref callback to accept only HTMLImageElement | null. Without the generic parameter, every call site would need an unsafe cast (ref as unknown as React.Ref<HTMLImageElement>), which defeats the purpose of writing the hook in TypeScript at all.

The memoization step matters because IntersectionObserverInit accepts threshold as either a single number or a number[]. Arrays fail strict equality (===) even when their contents are identical, so React's dependency-array comparison (which uses Object.is) sees a "changed" dependency on every render if the caller writes threshold: [0, 0.5, 1] directly in JSX. Serializing the array with JSON.stringify inside the useMemo dependency list sidesteps the need for a deep-equality library while remaining correct for the small, flat arrays threshold accepts.

The diagram below shows why an inline options literal causes the effect to rerun on every render, and how memoizing breaks that cycle.

Object Identity vs useMemo — Why the Observer Effect Reruns or Doesn't Two horizontal sequences across three renders. The top sequence shows an inline options object literal producing a new reference on every render, causing the observer effect to disconnect and recreate the observer each time. The bottom sequence shows a useMemo-wrapped options object producing the same reference across renders, so the effect runs only once. Inline literal useMemo Render 1 new object ref #1 Render 2 new object ref #2 Render 3 new object ref #3 effect reruns effect reruns Render 1 memoized ref #1 Render 2 same ref #1 Render 3 same ref #1 no rerun no rerun React's dependency comparison uses Object.is — same reference means the effect is skipped.

Comparison: Return-Shape Options

Shape Example Pros Cons
[ref, isIntersecting] const [ref, visible] = useIntersectionObserver() Minimal, fast to read at call sites Loses intersectionRatio and other entry fields
[ref, entry] const [ref, entry] = useIntersectionObserver() Full fidelity, one destructure Callers must null-check entry before first fire
{ ref, entry, isIntersecting } const { ref, isIntersecting } = useIntersectionObserver() Self-documenting, easy to add fields later Slightly more verbose destructuring

This guide implements the [ref, entry] tuple shape, since it gives callers full access to intersectionRatio and boundingClientRect without committing to a wider object contract prematurely.

Minimal Reproducible Example

The smallest version that compiles and runs, with no generics, no memoization, and no once support — useful for demonstrating the core mechanic before hardening it:

TypeScript
// Minimal version — do not ship this as-is, see Production-Safe Solution below
import { useState, useCallback, useEffect } from 'react';

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

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

  useEffect(() => {
    if (!node) return;
    const observer = new IntersectionObserver(([e]) => setEntry(e), options);
    observer.observe(node);
    return () => observer.disconnect();
  }, [node, options]); // BUG: options is a new object reference every render

  return [ref, entry];
}

The bug on the last line of the effect is the dependency array depending on options directly. Every parent re-render that passes { threshold: 0.5 } inline creates a new object, the effect sees a changed dependency, and the observer is torn down and recreated — needlessly, and potentially losing intersection state mid-transition.

Production-Safe Solution

The hardened version below fixes typing, memoization, and adds once/freezeOnceVisible:

TypeScript
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';

export interface UseIntersectionObserverOptions extends IntersectionObserverInit {
  /** Stop observing after the first intersection */
  once?: boolean;
  /** Keep reporting the last "visible" entry even after the element leaves the viewport */
  freezeOnceVisible?: boolean;
}

export function useIntersectionObserver<T extends Element = HTMLDivElement>(
  options: UseIntersectionObserverOptions = {}
): [(el: T | null) => void, IntersectionObserverEntry | undefined] {
  const { once = false, freezeOnceVisible = false, threshold = 0, root = null, rootMargin = '0px' } = options;

  const [node, setNode] = useState<T | null>(null);
  const [entry, setEntry] = useState<IntersectionObserverEntry>();
  const frozenRef = useRef(false);

  // Primitive/array-safe memo: rebuilds only when the actual values change,
  // not on every render where a caller passes a fresh object literal.
  const observerOptions = useMemo<IntersectionObserverInit>(
    () => ({ threshold, root, rootMargin }),
    // JSON.stringify handles array thresholds without deep-equal libraries
    [JSON.stringify(threshold), root, rootMargin]
  );

  const ref = useCallback((el: T | null) => setNode(el), []);

  useEffect(() => {
    if (typeof IntersectionObserver === 'undefined') return; // SSR / unsupported guard
    if (!node || frozenRef.current) return;

    const observer = new IntersectionObserver(([observedEntry]) => {
      setEntry(observedEntry);

      if (observedEntry.isIntersecting && once) {
        observer.unobserve(node);
        if (freezeOnceVisible) frozenRef.current = true;
      }
    }, observerOptions);

    observer.observe(node);
    return () => observer.disconnect();
  }, [node, observerOptions, once, freezeOnceVisible]);

  return [ref, entry];
}
JavaScript
// Plain JS equivalent (no generics, same runtime behavior)
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';

export function useIntersectionObserver(options = {}) {
  const { once = false, freezeOnceVisible = false, threshold = 0, root = null, rootMargin = '0px' } = options;
  const [node, setNode] = useState(null);
  const [entry, setEntry] = useState();
  const frozenRef = useRef(false);

  const observerOptions = useMemo(
    () => ({ threshold, root, rootMargin }),
    [JSON.stringify(threshold), root, rootMargin]
  );

  const ref = useCallback((el) => setNode(el), []);

  useEffect(() => {
    if (typeof IntersectionObserver === 'undefined' || !node || frozenRef.current) return;
    const observer = new IntersectionObserver(([observedEntry]) => {
      setEntry(observedEntry);
      if (observedEntry.isIntersecting && once) {
        observer.unobserve(node);
        if (freezeOnceVisible) frozenRef.current = true;
      }
    }, observerOptions);
    observer.observe(node);
    return () => observer.disconnect();
  }, [node, observerOptions, once, freezeOnceVisible]);

  return [ref, entry];
}

Usage in a lazy-loading image component, matching the generic type to the actual DOM element:

TypeScript
function LazyImage({ src, alt }: { src: string; alt: string }): JSX.Element {
  const [ref, entry] = useIntersectionObserver<HTMLImageElement>({
    threshold: 0.1,
    once: true,
  });
  const isVisible = entry?.isIntersecting ?? false;

  return (
    <img
      ref={ref}
      src={isVisible ? src : undefined}
      alt={alt}
      loading="lazy"
    />
  );
}

Verification Steps

  • Type-check the generic parameter. Call useIntersectionObserver<HTMLImageElement>() and confirm the returned ref callback's parameter type is HTMLImageElement | null with no cast required — tsc --noEmit should pass with zero errors.
  • Confirm the effect does not thrash. Add a console.count('observer effect ran') inside the useEffect body, render the component with an inline options literal from the parent, and verify the count stays at 1 after several parent re-renders — proof the memoization is working.
  • Write a Jest/Vitest test with a mock observer:
TypeScript
// Minimal IntersectionObserver mock for Jest/Vitest
class MockIntersectionObserver implements IntersectionObserver {
  readonly root = null;
  readonly rootMargin = '0px';
  readonly thresholds = [0];
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
  takeRecords = vi.fn(() => []);
  constructor(public callback: IntersectionObserverCallback) {}
}

beforeEach(() => {
  vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
});

test('disconnects on unmount', () => {
  const { unmount } = renderHook(() => useIntersectionObserver());
  unmount();
  // Assert the mock instance's disconnect was called — capture the instance
  // via a module-level array pushed in the mock constructor for full coverage.
});
  • Confirm once unobserves. Fire a mock entry with isIntersecting: true through the stored callback and assert observer.unobserve was called exactly once, and that a second fired entry does not call observe() again.

Common Mistakes

  • Depending on the raw options object in useEffect. As shown in the minimal example, this causes the observer to be torn down and recreated on every parent render. Always memoize or destructure to primitives.
  • Using useRef instead of a state-backed ref callback. This silently breaks whenever the observed node's identity changes without a full component unmount — the effect never re-runs to observe the new node.
  • Forgetting the SSR/unsupported guard. typeof IntersectionObserver === 'undefined' should be the first line inside the effect; skipping it throws a ReferenceError in test environments and non-browser renderers that don't polyfill the constructor.
  • Not resetting frozenRef between different targets. If the hook instance is reused across different logical targets (rare, but happens with key-based remounting bugs), a stale frozenRef.current = true silently prevents new observations. Scope the hook to one target per call site.
  • Treating entry as always defined. The first render before the effect fires has entry === undefined; always guard with entry?.isIntersecting ?? false rather than assuming a truthy default.
  • Publishing the hook without a peer dependency range. If this hook ships as an internal package consumed by multiple apps, declare react as a peerDependency with a version range covering both the hooks API (16.8+) and whichever concurrent-rendering features (18+) your test suite assumes — otherwise a consuming app on an older React version fails at runtime with an unhelpful "invalid hook call" error rather than a clear version-mismatch message at install time.
  • Skipping the root option's type. IntersectionObserverInit['root'] is Element | Document | null, not Element | null — omitting Document from a narrower custom options type causes a type error the moment a caller passes document as the root for whole-page intersection tracking.

Testing Utility: A Reusable Observer Mock Factory

Repeating the mock class in every test file gets tedious once a codebase has more than a few observer-dependent components. Extract a small factory that both records constructor calls and exposes a way to manually fire entries from the test body:

TypeScript
// test-utils/mock-intersection-observer.ts
type Callback = IntersectionObserverCallback;

export function installMockIntersectionObserver(): { trigger: (entries: Partial<IntersectionObserverEntry>[]) => void } {
  let lastCallback: Callback | null = null;

  class MockIntersectionObserver implements IntersectionObserver {
    readonly root = null;
    readonly rootMargin = '0px';
    readonly thresholds = [0];
    observe = vi.fn();
    unobserve = vi.fn();
    disconnect = vi.fn();
    takeRecords = vi.fn(() => []);
    constructor(callback: Callback) {
      lastCallback = callback;
    }
  }

  vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);

  return {
    trigger: (entries) => lastCallback?.(entries as IntersectionObserverEntry[], {} as IntersectionObserver),
  };
}

Import this in any test file that renders a component using useIntersectionObserver, call trigger([{ isIntersecting: true, intersectionRatio: 1 }]) inside act(...), and assert the resulting DOM state — this avoids re-implementing the mock class per test suite and keeps the once/freezeOnceVisible behavior under the same coverage as the rest of the hook.

FAQ

Should useIntersectionObserver return a tuple or an object?

A tuple ([ref, entry]) is more concise at call sites and matches the convention set by useState, but an object ({ ref, entry }) is more self-documenting when the hook returns three or more values. For a two-value return, prefer the tuple; once you add a third field like a manual reset function, switch to a named object so call sites do not need to memorize positional order.

How do I make the hook generic over different element types?

Declare the hook with a generic type parameter constrained to Element, such as function useIntersectionObserver<T extends Element = HTMLDivElement>(...), and type the internal ref state as T | null. This lets callers annotate useIntersectionObserver<HTMLImageElement>() and get a correctly typed ref callback without any type assertion at the call site.

Why should the options object be memoized before use in useEffect?

Object and array literals created inline in JSX (like threshold: [0, 0.5, 1]) produce a new reference on every render. If that object is placed directly in a useEffect dependency array, the effect reruns every render, disconnecting and recreating the observer continuously. Memoize the options with useMemo, or depend on individual primitive fields extracted from the options object instead of the object itself.


↑ Back to React Observer Hooks