En las aplicaciones web modernas, el cuello de botella al representar colecciones masivas de datos no reside en la potencia de procesamiento del motor V8 de JavaScript, sino en los costes físicos de los motores de renderizado del navegador (Blink en Chrome, Gecko en Firefox, WebKit en Safari) al instanciar y gestionar nodos nativos del DOM.
Intentar montar simultáneamente 10.000 o 100.000 elementos <div /> paraliza de inmediato la interfaz por tres motivos fundamentales:
ComputedStyle, capas de composición y listeners de eventos. 10.000 filas de tabla complejas superan con facilidad 300 MB de memoria en la pestaña.El estándar de la industria para resolver este problema es el patrón Virtual Windowing (Virtualización de ventanas).
En lugar de montar la totalidad del array de datos, la biblioteca de virtualización proyecta la ilusión de un documento infinito:
overflow-y: auto.totalSize) cuya altura equivale al producto del tamaño medio estimado por el número total de registros.transform: translateY(...), eludiendo por completo las costosas fases de Layout del documento.┌─────────────────────────────────────────────────────────┐
│ Contenedor de Scroll Exterior (overflow-y: auto, fixed) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Contenedor fantasma (totalSize = 100.000 * 50px) │ │
│ │ │ │
│ │ [ Elementos superiores ocultos: translateY ] │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ [Overscan: 3-5 elementos de búfer arriba] │ │ │
│ │ │ --------------------------------------------- │ │ │
│ │ │ │ │ │
│ │ │ VENTANA VISIBLE (VIEWPORT) │ │ │
│ │ │ Solo se montan 12-18 nodos DOM reales │ │ │
│ │ │ │ │ │
│ │ │ --------------------------------------------- │ │ │
│ │ │ [Overscan: 3-5 elementos de búfer abajo] │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ [ Elementos inferiores ocultos: espacio vacío ] │ │
│ │ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Define altura fija (ej. h-[600px] o h-screen), overflow-y: auto, propiedad contain: strict y asigna una ref.
Configura count (longitud del array), getScrollElement, estimateSize (estimación de altura) y overscan (5–8 para escritorio).
Crea un div interno con altura fija getTotalSize()px y posicionamiento relativo para preservar la barra de scroll nativa.
Recorre getVirtualItems(), vincula ref={measureElement} con data-index y posiciona mediante transform: translateY.
Comprueba en Chrome Performance la ausencia de caídas de frames y de Layout Shifts durante scrolls intensos.
¿Cuál es la diferencia arquitectónica fundamental entre la virtualización de ventanas (Windowing) y el Infinite Scroll clásico sin virtualizar?
El contenedor emite el evento scroll. useVirtualizer obtiene el scrollTop actualizado.
Búsqueda binaria sobre la caché de desplazamientos para determinar startIndex y endIndex con overscan.
React reconcilia y monta únicamente los elementos visibles, posicionándolos mediante transform de GPU.
measureElement detecta discrepancias respecto a estimateSize y actualiza la caché de desplazamientos.
En interfaces reales, las tarjetas varían según el texto, adjuntos o menús desplegables. Asignar una altura fija con estimateSize: () => 60 produce solapamiento de filas y saltos visuales.
TanStack Virtual soluciona esto integrándose de forma nativa con ResizeObserver:
estimateSize proporciona una aproximación heurística inicial antes de pintar el elemento.ref={rowVirtualizer.measureElement} junto con el atributo data-index={virtualRow.index}.ResizeObserver, obtiene las dimensiones reales tras aplicar estilos CSS y actualiza la caché del virtualizador.start de todas las filas posteriores, eliminando solapamientos.<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement} // Medición dinámica mediante ref callback
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<DynamicMessageCard message={messages[virtualRow.index]} />
</div>En chats, los mensajes nuevos se insertan en la parte inferior mientras que el historial antiguo se carga hacia arriba. Si se anteponen 20 mensajes que suman 800px, el navegador preserva el scrollTop numérico anterior, lo que provoca que el viewport salte bruscamente 800px hacia abajo.
Para evitar este salto visual se implementa una compensación de desplazamiento programática:
// Algoritmo de compensación al cargar historial hacia arriba
const previousScrollHeight = containerRef.current.scrollHeight;
await loadOlderMessages();
// En el siguiente fotograma de animación compensamos la diferencia
requestAnimationFrame(() => {
if (containerRef.current) {
const deltaHeight = containerRef.current.scrollHeight - previousScrollHeight;
containerRef.current.scrollTop += deltaHeight;
}
});¿Por qué es fundamental desplazar las filas virtualizadas con la propiedad CSS transform: translateY(...) en lugar de utilizar top?
A continuación se muestra un componente listo para producción que combina useVirtualizer, medición dinámica de alturas y disparador automático de paginación infinita:
import React, { useRef, useEffect, useCallback } from 'react';
import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
export interface FeedArticle {
id: string;
title: string;
body: string;
author: string;
createdAt: string;
}
interface VirtualInfiniteFeedProps {
items: FeedArticle[];
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
overscan?: number;
}
export const VirtualInfiniteFeed: React.FC<VirtualInfiniteFeedProps> = ({
items,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
overscan = 5,
}) => {
const parentRef = useRef<HTMLDivElement | null>(null);
// Total de filas = número de artículos + 1 fila de loader (si existe siguiente página)
const totalCount = hasNextPage ? items.length + 1 : items.length;
const rowVirtualizer = useVirtualizer({
count: totalCount,
getScrollElement: () => parentRef.current,
// Estimación heurística inicial antes del montaje en el DOM
estimateSize: useCallback(() => 100, []),
overscan,
});
const virtualRows = rowVirtualizer.getVirtualItems();
// Disparador preventivo de paginación al aproximarse al final del scroll
useEffect(() => {
const lastItem = virtualRows[virtualRows.length - 1];
if (!lastItem) return;
if (
lastItem.index >= items.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage();
}
}, [hasNextPage, fetchNextPage, isFetchingNextPage, virtualRows, items.length]);
return (
<div
ref={parentRef}
className="w-full h-[650px] overflow-y-auto border border-border rounded-xl bg-bg-surface relative custom-scrollbar"
style={{ contain: 'strict' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualRows.map((virtualRow: VirtualItem) => {
const isLoaderRow = virtualRow.index > items.length - 1;
const item = items[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 w-full p-4 border-b border-border/50 transition-colors hover:bg-bg-elevated/40"
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
<div className="flex items-center justify-center py-4 text-accent-cyan font-mono text-xs animate-pulse">
Cargando registros adicionales...
</div>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-accent-violet font-mono">
#{item.id} — {item.author}
</span>
<span className="text-[11px] text-text-muted">{item.createdAt}</span>
</div>
<h4 className="text-sm font-semibold text-text-primary leading-snug">
{item.title}
</h4>
<p className="text-xs text-text-muted leading-relaxed whitespace-pre-wrap">
{item.body}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
);
};Asignar key={virtualRow.index} provoca que al hacer scroll React reutilice nodos DOM con estado interno obsoleto (por ejemplo, inputs abiertos o acordeones desplegados previamente).
// ❌ Antipatrón: al hacer scroll el componente se recicla y el estado local queda fijado
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.index} style={{ transform: `translateY(${virtualRow.start}px)` }}>
<RowItem item={items[virtualRow.index]} />
</div>
))}
// ✅ Solución correcta: utilizar siempre la clave virtualRow.key del virtualizador
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.key} style={{ transform: `translateY(${virtualRow.start}px)` }}>
<RowItem item={items[virtualRow.index]} />
</div>
))}Sin aislamiento del renderizado, el navegador se ve obligado a recalcular estilos y geometrías de todo el documento cada vez que cambia la altura del contenedor fantasma (totalSize).
// ❌ Antipatrón: el scroll de la lista virtual provoca reflow en toda la página
<div ref={parentRef} className="overflow-auto h-[500px]">
{/* Contenido */}
</div>
// ✅ Solución correcta: aislar layout, estilo y pintura dentro del contenedor
<div
ref={parentRef}
className="overflow-auto h-[500px]"
style={{ contain: 'strict' }}
>
{/* Contenido */}
</div>Modificar la propiedad top activa las etapas de Layout y Paint en cada fotograma de desplazamiento. Utilizar transform: translateY(...) traslada el posicionamiento a la etapa de Compositing en la GPU, garantizando 60/120 FPS.
// ❌ Antipatrón: Layout Thrashing en cada píxel de scroll
<div style={{ position: 'absolute', top: `${virtualRow.start}px` }}>
// ✅ Solución correcta: Aceleración por GPU
<div style={{ position: 'absolute', top: 0, left: 0, transform: `translateY(${virtualRow.start}px)` }}>