Рендеринг более 1 000 сложных DOM-узлов парализует браузерный поток перекомпоновки (Reflow) и резко увеличивает задержку взаимодействия (INP). Архитектура виртуализации оконного типа (Windowing) сокращает потребление оперативной памяти с сотен мегабайт до константного пула узлов, обеспечивая стабильные 60/120 FPS независимо от объема выборки (100 000+ элементов).
В современных корпоративных панелях управления, аналитических таблицах и каталогах интернет-магазинов фронтенд сталкивается с тремя критическими барьерами:
useState делает невозможным обмен ссылкой с коллегой или сохранение фильтра в закладках браузера. Необходим двунаправленный типобезопасный адаптер между стейтом и window.location.search.Связка библиотеки TanStack Virtual v3 (@tanstack/react-virtual) и библиотеки nuqs (Type-safe search params state manager) решает эти задачи на уровне эталонной промышленной архитектуры 2026 года.
Виртуализатор не скрывает скрытые элементы через display: none. Он физически удаляет их из DOM-дерева, создавая обертку с общей расчетной высотой (Virtual Spacer) и позиционируя активный срез элементов через transform: translateY(...):
┌────────────────────────────────────────────────────────┐
│ Внешний Scroll Container (overflow-y: auto, h: 600px) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Virtual Spacer Box (totalSize: 5 000 000 px) │ │
│ │ │ │
│ │ [ Невидимый буфер сверху: элементы 0..419 ] │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Элемент #420 (translateY: 21 000px) │ │ │ ◄── Overscan Buffer
│ │ ├────────────────────────────────────────────┤ │ │
│ │ │ Элемент #421 (translateY: 21 050px) │ │ │
│ │ ├────────────────────────────────────────────┤ │ │ ◄── Видимое окно
│ │ │ Элемент #422 (translateY: 21 100px) │ │ │ (Viewport)
│ │ ├────────────────────────────────────────────┤ │ │
│ │ │ Элемент #423 (translateY: 21 150px) │ │ │ ◄── Overscan Buffer
│ │ └────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [ Невидимый буфер снизу: элементы 424..100 000 ] │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘scrollTop контейнера прокрутки.startIndex) и последнего (endIndex) видимого элемента с учетом упреждающего буфера (overscan: 5).ResizeObserver к каждому смонтированному элементу и обновляет кэш высот без перезагрузки всего списка.?q=...&category=...&scroll=420) в URL через неблокирующие переходы браузерного History API.Чтение и строгая валидация поискового запроса, сортировки и якорного индекса через nuqs парсеры parseAsString / parseAsInteger.
Конфигурация count, getScrollElement, estimateSize и функции measureElement для динамических высот.
Определение активного подмножества virtualItems и генерация общего контейнера с высотой totalSize.
Рендеринг активного слайса с inline-стилями transform translateY и автоматический замер фактического DOM-размера.
Восстановление позиции прокрутки при возврате на страницу через virtualizer.scrollToIndex(savedIndex, { align: 'start' }).
Почему в виртуализированных списках с динамической высотой элементов критически важно передавать ref={virtualizer.measureElement} на корневую ноду каждого элемента?
Ниже представлен эталонный компонент виртуализированного каталога с динамической высотой карточек и синхронизацией поискового фильтра в URL через nuqs:
'use client'
import React, { useRef, useTransition } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useQueryState, parseAsString, parseAsInteger } from 'nuqs'
import { Search, Loader2 } from 'lucide-react'
interface ProductItem {
id: string
title: string
description: string
price: number
category: string
}
interface VirtualCatalogProps {
products: ProductItem[]
}
export function VirtualCatalog({ products }: VirtualCatalogProps) {
const parentRef = useRef<HTMLDivElement>(null)
const [isPending, startTransition] = useTransition()
// 1. Двунаправленный Type-safe URL State через Nuqs
const [search, setSearch] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })
)
const [scrollIndex, setScrollIndex] = useQueryState(
'scroll',
parseAsInteger.withDefault(0).withOptions({ shallow: true })
)
// 2. Клиентская фильтрация выборки
const filteredProducts = React.useMemo(() => {
if (!search.trim()) return products
const q = search.toLowerCase()
return products.filter(
p => p.title.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)
)
}, [products, search])
// 3. Конфигурация TanStack Virtual v3
const virtualizer = useVirtualizer({
count: filteredProducts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120, // Базовая оценка высоты карточки
overscan: 5, // Рендерим по 5 элементов выше и ниже видимой зоны
initialOffset: scrollIndex * 120
})
const virtualItems = virtualizer.getVirtualItems()
return (
<div className="space-y-4 w-full">
{/* Панель поиска с синхронизацией в URL */}
<div className="relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
value={search}
onChange={e => {
const val = e.target.value
startTransition(() => {
setSearch(val || null)
setScrollIndex(null)
})
}}
placeholder="Поиск по 100 000+ товарам (сохраняется в URL ?q=...)"
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-border bg-bg-surface text-sm focus:outline-none focus:border-accent-cyan"
/>
{isPending && (
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-accent-cyan animate-spin" />
)}
</div>
{/* Индикатор результатов */}
<div className="flex items-center justify-between text-xs font-mono text-text-muted">
<span>Найдено записей: {filteredProducts.length.toLocaleString('ru-RU')}</span>
<span>Отрендерено в DOM: {virtualItems.length} узлов</span>
</div>
{/* Ограниченный Scroll-контейнер */}
<div
ref={parentRef}
className="h-[600px] w-full overflow-y-auto border border-border rounded-2xl bg-bg-surface/50 custom-scrollbar p-2 relative"
>
{/* Виртуальный спейсер с суммарной вычисленной высотой */}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}
>
{virtualItems.map(virtualRow => {
const item = filteredProducts[virtualRow.index]
if (!item) return null
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`
}}
className="p-1.5"
>
<div className="p-4 rounded-xl border border-border bg-bg-surface hover:border-accent-cyan/40 transition-colors shadow-xs">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono text-accent-cyan">#{virtualRow.index + 1}</span>
<span className="text-xs font-mono px-2 py-0.5 rounded bg-bg-base border border-border text-text-muted">
{item.category}
</span>
</div>
<h3 className="text-base font-bold text-text-primary mt-1">{item.title}</h3>
<p className="text-xs text-text-muted mt-1 leading-relaxed">{item.description}</p>
<div className="mt-3 flex items-center justify-between pt-2 border-t border-border/50">
<span className="text-sm font-black font-mono text-emerald-400">
${item.price.toFixed(2)}
</span>
<button
type="button"
onClick={() => setScrollIndex(virtualRow.index)}
className="text-[11px] font-mono text-accent-cyan hover:underline cursor-pointer"
>
Сохранить якорь в URL
</button>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}Если позиционировать виртуальные элементы через margin-top или относительный поток документа, каждое изменение скролла вызовет повторный расчет макета (Reflow) всего документа. Позиционирование через position: absolute; top: 0; transform: translateY(${virtualRow.start}px) выносит отрисовку на видеокарту (GPU Compositing Layer), исключая падение частоты кадров.
Передача обычного порядкового индекса key={index} приводит к тому, что при скролле React переиспользует старые экземпляры DOM-нод со старыми внутренними стейтами (например, раскрытыми формами). Обязательно используйте virtualRow.key, сгенерированный самим виртуализатором, либо стабильный уникальный item.id.
Синхронный вызов history.pushState при скролле 60 раз в секунду забивает очередь браузерных событий и приводит к ошибке браузера «Throttling history state updates». Библиотека nuqs с параметром throttleMs: 300 агрегирует обновления и безопасно записывает их без фризов.
Что произойдет, если в хуке useVirtualizer установить параметр overscan: 0?