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:
- Generic element type parameter — lets TypeScript infer the correct DOM element interface for
ref.currentwithout a manual cast at the call site. - A
useState-backed ref callback — unlikeuseRef, updating state triggers a re-render, which lets auseEffectdepending on that state value re-run precisely when the node attaches or detaches. - A memoized options object —
IntersectionObserverInitfields (threshold,root,rootMargin) are primitives or arrays; arrays are reference types, so athreshold: [0, 1]literal must either be defined outside the render function or wrapped inuseMemo. - 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) — callunobserve()the instantisIntersectingbecomestrueand 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.
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:
// 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:
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];
}
// 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:
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 isHTMLImageElement | nullwith no cast required —tsc --noEmitshould pass with zero errors. - Confirm the effect does not thrash. Add a
console.count('observer effect ran')inside theuseEffectbody, render the component with an inline options literal from the parent, and verify the count stays at1after several parent re-renders — proof the memoization is working. - Write a Jest/Vitest test with a mock observer:
// 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
onceunobserves. Fire a mock entry withisIntersecting: truethrough the stored callback and assertobserver.unobservewas called exactly once, and that a second fired entry does not callobserve()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
useRefinstead 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 aReferenceErrorin test environments and non-browser renderers that don't polyfill the constructor. - Not resetting
frozenRefbetween different targets. If the hook instance is reused across different logical targets (rare, but happens with key-based remounting bugs), a stalefrozenRef.current = truesilently prevents new observations. Scope the hook to one target per call site. - Treating
entryas always defined. The first render before the effect fires hasentry === undefined; always guard withentry?.isIntersecting ?? falserather 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
reactas apeerDependencywith 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
rootoption's type.IntersectionObserverInit['root']isElement | Document | null, notElement | null— omittingDocumentfrom a narrower custom options type causes a type error the moment a caller passesdocumentas 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:
// 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.
Related
- React Observer Hooks — the parent guide covering
useResizeObserverand shared-observer patterns - Fixing Doubled Observer Callbacks in React Strict Mode
- IntersectionObserver API Deep Dive — option semantics this hook wraps
- Building a Lazy Image Loader with IntersectionObserver — the plain-JS terminal-state pattern this hook's
onceoption mirrors
↑ Back to React Observer Hooks