useIntersectionObserver — Observa cuándo los elementos entran o salen de la ventana gráfica
Receta
import { useState, useEffect, useRef, useCallback } from "react";
interface UseIntersectionObserverOptions {
/** Elemento que se usa como ventana gráfica. Predeterminado: ventana gráfica del navegador */
root?: Element | null;
/** Margen alrededor de la raíz. Predeterminado: "0px" */
rootMargin?: string;
/** Porcentaje del elemento visible para disparar. 0-1 o array. Predeterminado: 0 */
threshold?: number | number[];
/** Solo disparar una vez (p. ej., para lazy loading). Predeterminado: false */
triggerOnce?: boolean;
/** Comenzar a observar inmediatamente. Predeterminado: true */
enabled?: boolean;
}
interface UseIntersectionObserverReturn {
/** Ref para adjuntar al elemento de destino */
ref: (node: Element | null) => void;
/** La última entrada de IntersectionObserverEntry */
entry: IntersectionObserverEntry | null;
/** Si el elemento está intersecando actualmente */
isIntersecting: boolean;
}
function useIntersectionObserver(
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn {
const {
root = null,
rootMargin = "0px",
threshold = 0,
triggerOnce = false,
enabled = true,
} = options;
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const nodeRef = useRef<Element | null>(null);
const frozenRef = useRef(false);
const cleanup = useCallback(() => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
}, []);
// Patrón de callback ref para dirigirse flexiblemente a elementos
const ref = useCallback(
(node: Element | null) => {
// Limpiar observer anterior
cleanup();
nodeRef.current = node;
if (!node || !enabled || frozenRef.current) return;
if (typeof IntersectionObserver === "undefined") return;
observerRef.current = new IntersectionObserver(
([observedEntry]) => {
setEntry(observedEntry);
if (triggerOnce && observedEntry.isIntersecting) {
frozenRef.current = true;
cleanup();
}
},
{ root, rootMargin, threshold }
);
observerRef.current.observe(node);
},
[root, rootMargin, threshold, triggerOnce, enabled, cleanup]
);
// Limpieza al desmontar
useEffect(() => {
return cleanup;
}, [cleanup]);
return {
ref,
entry,
isIntersecting: entry?.isIntersecting ?? false,
};
}Cuándo usarlo: Necesitas imágenes cargadas perezosamente, disparadores de scroll infinito, efectos de animación al desplazarse o seguimiento de qué secciones ha desplazado el usuario.
Ejemplo de trabajo
"use client";
// Imagen cargada perezosamente
function LazyImage({ src, alt }: { src: string; alt: string }) {
const { ref, isIntersecting } = useIntersectionObserver({
triggerOnce: true,
rootMargin: "200px", // Comenzar a cargar 200px antes de ser visible
});
return (
<div ref={ref} style={{ minHeight: 200, background: "#f0f0f0" }}>
{isIntersecting ? (
<img src={src} alt={alt} style={{ width: "100%" }} />
) : (
<div style={{ padding: 20, color: "#999" }}>Cargando...</div>
)}
</div>
);
}
// Disparador de scroll infinito
function InfiniteList() {
const [items, setItems] = useState<number[]>([1, 2, 3, 4, 5]);
const [loading, setLoading] = useState(false);
const { ref, isIntersecting } = useIntersectionObserver({
threshold: 1.0,
});
useEffect(() => {
if (!isIntersecting || loading) return;
setLoading(true);
// Simular llamada a API
setTimeout(() => {
setItems((prev) => [
...prev,
...Array.from({ length: 5 }, (_, i) => prev.length + i + 1),
]);
setLoading(false);
}, 500);
}, [isIntersecting, loading]);
return (
<div>
{items.map((item) => (
<div key={item} style={{ padding: 24, borderBottom: "1px solid #eee" }}>
Elemento {item}
</div>
))}
<div ref={ref} style={{ padding: 20, textAlign: "center" }}>
{loading ? "Cargando más..." : "Desplázate para más"}
</div>
</div>
);
}
// Animar al desplazarse
function AnimatedSection({ children }: { children: React.ReactNode }) {
const { ref, isIntersecting } = useIntersectionObserver({
threshold: 0.2,
triggerOnce: true,
});
return (
<div
ref={ref}
style={{
opacity: isIntersecting ? 1 : 0,
transform: isIntersecting ? "translateY(0)" : "translateY(20px)",
transition: "opacity 0.6s ease, transform 0.6s ease",
}}
>
{children}
</div>
);
}Lo que demuestra:
- Imagen perezosa: Solo se carga cuando está a 200 px de la ventana gráfica, se dispara una vez
- Scroll infinito: Dispara la obtención de datos cuando el elemento centinela es completamente visible (
threshold: 1.0) - Animar al desplazarse: Se desvanece en el contenido cuando es visible al 20%, usando
triggerOncepara que no se reproduzca nuevamente
Profundidad
Cómo funciona
- Patrón de callback ref: En lugar de un
useRefsimple, el hook utiliza un callback ref(node) => .... Esto permite que vuelva a observar cuando el elemento de destino cambia (p. ej., renderización condicional). - IntersectionObserver: Una API del navegador que observa asincrónicamente los cambios de visibilidad. Se ejecuta fuera del hilo principal, por lo que es más eficiente que los listeners de scroll.
- triggerOnce: Cuando está habilitado, el observer se desconecta después de la primera intersección, evitando callbacks posteriores y mejorando el rendimiento para contenido cargado perezosamente.
- rootMargin: Una cadena de margen similar a CSS (
"200px 0px") que expande el área de detección, permitiendo la precarga antes de que el elemento sea visible. - threshold: Un número (0 a 1) o array de números que indica qué porcentaje del elemento debe estar visible para disparar.
0significa cualquier píxel;1significa completamente visible.
Parámetros y valores de retorno
| Opción | Tipo | Predeterminado | Descripción |
|---|---|---|---|
root | Element o null | null (ventana gráfica) | Ancestro desplazable para usar como ventana gráfica |
rootMargin | string | "0px" | Margen alrededor de raíz para expandir detección |
threshold | number o number[] | 0 | Proporción de visibilidad para disparar |
triggerOnce | boolean | false | Desconectar después de la primera intersección |
enabled | boolean | true | Si se debe observar |
| Retorno | Tipo | Descripción |
|---|---|---|
ref | (node: Element o null) => void | Callback ref para adjuntar a destino |
entry | IntersectionObserverEntry o null | Última entrada de observer |
isIntersecting | boolean | Si el elemento es visible |
Variaciones
Múltiples elementos: Observa muchos elementos con un único observer para mejor rendimiento:
function useIntersectionObserverMultiple(
options: IntersectionObserverInit = {}
) {
const [entries, setEntries] = useState<Map<Element, IntersectionObserverEntry>>(new Map());
const observer = useRef<IntersectionObserver | null>(null);
const observe = useCallback((node: Element) => {
if (!observer.current) {
observer.current = new IntersectionObserver((observed) => {
setEntries((prev) => {
const next = new Map(prev);
observed.forEach((e) => next.set(e.target, e));
return next;
});
}, options);
}
observer.current.observe(node);
}, [options]);
return { observe, entries };
}Con proporción de intersección: Rastrear el porcentaje exacto de visibilidad para animaciones vinculadas al scroll:
const { entry } = useIntersectionObserver({
threshold: Array.from({ length: 101 }, (_, i) => i / 100),
});
const ratio = entry?.intersectionRatio ?? 0; // 0.0 a 1.0Notas de TypeScript
- El callback ref acepta
Element | null, compatible con cualquier elemento HTML o SVG. IntersectionObserverEntryes un tipo de navegador integrado conisIntersecting,intersectionRatio,boundingClientRect, etc.- Las opciones utilizan una interfaz para que puedan extenderse en hooks consumidores.
Gotchas
- Observer no creado durante SSR —
IntersectionObserverno existe en el servidor. Solución: La guardiatypeof IntersectionObserver === "undefined"maneja esto. - Entrada obsoleta después del desmontaje — El estado de entrada puede hacer referencia a un elemento desconectado. Solución: La limpieza desconecta el observer; los consumidores deben proteger con
isIntersecting. - Array de threshold crea muchos callbacks — Usar 100 thresholds dispara el callback 100 veces mientras el elemento se desplaza. Solución: Solo usa thresholds granulares cuando sea necesario para animaciones vinculadas al scroll.
- Callback ref recrea observer — Si las dependencias del callback ref cambian, crea un nuevo observer. Solución: Memoiza las opciones o pasa referencias estables.
- Formato rootMargin — Debe ser una cadena de estilo CSS como
"10px 20px", no un número. Solución: Valida el formato o documéntalo claramente.
Alternativas
| Paquete | Nombre del Hook | Notas |
|---|---|---|
react-intersection-observer | useInView | Más popular, completo |
usehooks-ts | useIntersectionObserver | Simple, basado en ref |
ahooks | useInViewport | Parte de una colección grande |
@uidotdev/usehooks | useIntersectionObserver | Implementación mínima |
framer-motion | useInView | Enfocado en animación |
Preguntas frecuentes
¿Qué es el patrón de callback ref y por qué este hook lo usa en lugar de useRef?
- Un callback ref es una función
(node) => ...que React llama cuando el elemento se monta o cambia. - A diferencia de
useRef, permite que el hook vuelva a observar cuando el elemento de destino se renderiza condicionalmente. - El observer se crea dentro del callback, asegurando que siempre se dirija al nodo correcto.
¿Qué hace la opción triggerOnce internamente?
Cuando triggerOnce es verdadero y el elemento se vuelve visible, el hook establece una bandera congelada, desconecta el observer y detiene la vigilancia. Esto evita callbacks posteriores, mejorando el rendimiento para contenido cargado perezosamente.
¿Qué es rootMargin y cómo lo uso para precarga?
rootMargin es una cadena de margen similar a CSS (p. ej., "200px 0px") que expande el área de detección. Configurar rootMargin: "200px" dispara la intersección 200px antes de que el elemento entre en la ventana gráfica, permitiendo la precarga de imágenes o datos.
¿Qué valores puede tomar threshold?
- Un número único de 0 a 1 (p. ej.,
0.5significa 50% visible). - Un array de números para múltiples puntos de disparo (p. ej.,
[0, 0.25, 0.5, 0.75, 1]). 0significa cualquier píxel visible;1significa completamente visible.
Gotcha: Usé 100 valores de threshold y mi callback se dispara excesivamente. ¿Qué debo hacer?
Los thresholds granulares (p. ej., Array.from({ length: 101 }, (_, i) => i / 100)) disparan el callback 100 veces mientras el elemento se desplaza. Solo usa esto para animaciones vinculadas al scroll. Para lazy loading, threshold: 0 o threshold: 1 es suficiente.
Gotcha: El observer no funciona durante SSR. ¿Cómo se maneja?
El hook protege con typeof IntersectionObserver === "undefined". En el servidor, retorna temprano sin crear un observer. isIntersecting predeterminado es false.
¿Cómo observo múltiples elementos con un único observer?
Usa la variación de múltiples elementos mostrada en la sección Variaciones. Crea un IntersectionObserver y llama .observe(node) para cada elemento. Almacena entradas en un Map indexado por el elemento de destino.
¿Puedo usar este hook con elementos SVG?
Sí. El callback ref acepta Element | null, que cubre tanto elementos HTML como SVG. IntersectionObserver funciona con cualquier tipo de elemento DOM.
¿Cómo se escribe IntersectionObserverEntry en TypeScript?
IntersectionObserverEntry es un tipo de navegador integrado con propiedades incluyendo isIntersecting, intersectionRatio, boundingClientRect, rootBounds y target. No se necesitan tipos personalizados.
¿Qué hace la opción enabled?
Cuando enabled es false, el callback ref omite crear un observer. Esto te permite pausar condicionalmente la observación sin desmontar el elemento, y volver a habilitarla más tarde configurando enabled de nuevo a true.
Relacionado
- useScrollToTop — UI basado en scroll
- useEventListener — detección alternativa de scroll
- useWindowSize — seguimiento de dimensiones de ventana gráfica