useIntersectionObserver, a callback-driven hook that manages observer instantiation, cleanup, and closure stability.
Architecture Decisions
- Callback-Driven Design: The hook does not return a boolean. Visibility is contextual. A lazy image might trigger at 200px before viewport entry (
rootMargin), while an analytics event requires 50% intersection (threshold). Returning raw IntersectionObserverEntry[] lets the consumer define visibility semantics.
- Stable Callback References: Internally, the hook stores the callback in a ref (using a
useLatest pattern). This prevents stale closure bugs where the observer captures outdated props or state from the initial render.
- SSR Safety: Observer instantiation is deferred to
useEffect. This prevents hydration mismatches and server crashes where window or IntersectionObserver are undefined.
- Explicit Lifecycle Control: The hook returns a
stop() function. While unmount triggers automatic cleanup, explicit disconnection is critical for one-shot events and performance optimization in long lists.
Implementation Patterns
Pattern 1: Deferred Media Loading
Load assets only when they approach the viewport. The rootMargin expands the trigger boundary, allowing network requests to complete before the element becomes visible.
import { useRef, useState } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
interface DeferredMediaProps {
mediaUrl: string;
placeholderHeight: number;
onLoadComplete?: () => void;
}
export function DeferredMediaLoader({ mediaUrl, placeholderHeight, onLoadComplete }: DeferredMediaProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
const disconnectObserver = useIntersectionObserver(
containerRef,
([entry]) => {
if (entry.isIntersecting) {
setIsReady(true);
disconnectObserver(); // One-shot: eliminate observer after commitment
onLoadComplete?.();
}
},
{ rootMargin: '250px' } // Pre-fetch boundary
);
return (
<div ref={containerRef} style={{ minHeight: placeholderHeight, position: 'relative' }}>
{isReady ? (
<img src={mediaUrl} alt="Deferred content" loading="lazy" style={{ width: '100%', display: 'block' }} />
) : (
<div className="media-skeleton" aria-hidden="true" />
)}
</div>
);
}
Pattern 2: Exposure-Based Analytics
Track user attention without inflating metrics during rapid scrolling. The threshold enforces a product-defined visibility standard.
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
interface ExposureTrackerProps {
contentId: string;
onExpose: (id: string) => void;
minimumVisibilityRatio?: number;
}
export function ExposureTracker({ contentId, onExpose, minimumVisibilityRatio = 0.5 }: ExposureTrackerProps) {
const sectionRef = useRef<HTMLElement>(null);
const terminateObservation = useIntersectionObserver(
sectionRef,
([entry]) => {
if (entry.isIntersecting && entry.intersectionRatio >= minimumVisibilityRatio) {
onExpose(contentId);
terminateObservation(); // Prevent duplicate events on scroll-back
}
},
{ threshold: [0, 0.25, 0.5, 0.75, 1.0] } // Granular ratio tracking
);
return <section ref={sectionRef} data-content-id={contentId}>{/* children */}</section>;
}
Pattern 3: Pagination Sentinel
Infinite scroll requires a persistent trigger. Unlike one-shot patterns, the observer must remain active across multiple data fetches.
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
interface PaginationSentinelProps {
hasRemainingData: boolean;
fetchNextPage: () => void;
isLoading: boolean;
}
export function PaginationSentinel({ hasRemainingData, fetchNextPage, isLoading }: PaginationSentinelProps) {
const triggerRef = useRef<HTMLDivElement>(null);
useIntersectionObserver(
triggerRef,
([entry]) => {
if (entry.isIntersecting && hasRemainingData && !isLoading) {
fetchNextPage();
}
},
{ rootMargin: '100px' } // Early fetch trigger
);
if (!hasRemainingData) return null;
return (
<div ref={triggerRef} style={{ height: 1, visibility: 'hidden' }} aria-hidden="true" />
);
}
Pitfall Guide
1. Stale Closure Capture
Explanation: A hand-rolled useEffect creates the observer once. If the callback references props or state, it freezes those values at mount time. Subsequent updates are ignored.
Fix: Use a hook that stabilizes the callback via useRef/useLatest, or implement functional state updates. Never recreate the observer on every render.
2. Observer Leakage in Long Lists
Explanation: Rendering 500 components each with an active observer consumes memory and CPU cycles, even after elements are loaded.
Fix: Call stop() immediately after the first intersection for one-shot use cases. For persistent lists, pair observers with virtualization (react-window or @tanstack/virtual) to limit DOM nodes.
3. Threshold Misalignment with Product Logic
Explanation: Using threshold: 0 triggers on a single pixel. This inflates analytics and causes premature lazy loads. Using threshold: 1 may never fire if elements are clipped or partially obscured.
Fix: Define visibility explicitly. Use 0.5 for content exposure, 0.1 for pre-loading, or array thresholds for progressive animations. Validate against actual viewport clipping.
Explanation: The default root is the browser viewport. If your UI scrolls inside a modal, sidebar, or fixed-height container, the observer will never fire correctly.
Fix: Pass the scroll container's ref to the root option. Ensure the container has overflow: auto/scroll and a defined height.
5. Hydration Mismatch on SSR
Explanation: Server renders lack window and IntersectionObserver. If visibility state drives initial markup, client hydration will mismatch, causing React warnings or UI jumps.
Fix: Initialize visibility state to false or a safe default. Let the hook update it post-mount. Use suppressHydrationWarning only for non-critical visual differences.
6. Blocking the Callback with Synchronous Work
Explanation: Performing heavy calculations, synchronous DOM reads, or blocking network calls inside the intersection callback delays the browser's batched processing.
Fix: Keep callbacks lightweight. Delegate heavy work to requestIdleCallback, setTimeout, or React state updates that trigger asynchronous effects.
7. Over-Engineering Boolean Wrappers
Explanation: Creating a custom useIsVisible hook that returns [boolean, () => void] often obscures intersectionRatio and rootMargin tuning.
Fix: Use useElementVisibility (or equivalent) only when a simple boolean is sufficient. Drop to the raw callback hook when you need ratio tracking, custom roots, or multi-threshold animations.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple show/hide based on viewport entry | useElementVisibility | Minimal boilerplate, boolean output, auto-managed lifecycle | Low |
| Lazy media loading with pre-fetch buffer | useIntersectionObserver + rootMargin | Early network trigger, explicit stop() prevents lingering observers | Low |
| Exposure analytics with strict visibility rules | useIntersectionObserver + array threshold | Granular ratio tracking, one-shot termination prevents metric inflation | Low |
| Infinite scroll in fixed-height container | useIntersectionObserver + custom root | Accurate intersection relative to scroll container, not viewport | Low |
| 1000+ item list with visibility tracking | Virtualization + sentinel observer | Reduces DOM nodes, limits observer count, maintains 60fps scroll | Medium (implementation complexity) |
| Scroll-linked parallax/animations | useIntersectionObserver + intersectionRatio | Smooth ratio-driven transforms, avoids layout thrashing | Low |
Configuration Template
A production-ready, typed wrapper that standardizes observer options across your codebase:
import { RefObject } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
export interface VisibilityConfig {
threshold?: number | number[];
rootMargin?: string;
root?: RefObject<Element | null> | null;
once?: boolean;
}
export function useVisibilityDetection(
target: RefObject<Element | null>,
onIntersect: (entry: IntersectionObserverEntry) => void,
config: VisibilityConfig = {}
) {
const { threshold = 0.1, rootMargin = '0px', root = null, once = false } = config;
const disconnect = useIntersectionObserver(
target,
([entry]) => {
if (entry.isIntersecting) {
onIntersect(entry);
if (once) disconnect();
}
},
{ threshold, rootMargin, root }
);
return disconnect;
}
Quick Start Guide
- Install the dependency:
npm install @reactuses/core
- Create a target ref:
const targetRef = useRef<HTMLDivElement>(null);
- Initialize the hook: Pass the ref, a callback, and optional configuration.
- Attach to JSX: Bind the ref to the element you want to track.
- Handle lifecycle: Call the returned
stop() function for one-shot events, or let unmount handle cleanup for persistent observers.
Viewport awareness is no longer a performance liability when implemented correctly. By leveraging asynchronous observation, stabilizing callback references, and aligning thresholds with product requirements, you eliminate layout thrashing, prevent memory leaks, and deliver consistent 60fps interactions across all device classes.