Проблематика одностороннего скролла
Большинство готовых решений для бесконечной прокрутки ориентированы исключительно на загрузку данных при приближении к нижнему краю списка. Такой подход полностью устраивает ленты новостей, галереи изображений и аналогичные интерфейсы, где пользователь перемещается только вниз. Однако в ряде реальных задач — чат‑приложения, лог‑вьюеры, временные шкалы — требуется возможность перемещаться в обе стороны. При скролле вверх возникает уникальная проблема: новые элементы добавляются в начало списка, а текущая позиция прокрутки смещается, что приводит к «скачку» контента и нарушению пользовательского опыта.
Математика scroll offset
Ключевым элементом решения является управление смещением прокрутки (scrollTop). При добавлении элементов в начало списка необходимо компенсировать рост высоты контейнера, чтобы визуальная позиция пользователя оставалась неизменной. Формула проста:
newScrollTop = oldScrollTop + heightOfAddedItems
Где heightOfAddedItems — суммарная высота всех новых строк, вычисляемая заранее (например, через измерения DOM‑элементов или заранее известные размеры). При подгрузке данных снизу смещение не требуется, достаточно проверить, достигнут ли нижний порог (scrollHeight - scrollTop - clientHeight).
Выбор библиотеки: @tanstack/react-virtual
Для работы с большими списками в React оптимально использовать виртуализацию — рендерятся только те элементы, которые находятся в видимой области. Пакет @tanstack/react-virtual (ранее react-virtual) предоставляет гибкий API, позволяющий задавать как количество видимых элементов, так и их размеры в реальном времени. Библиотека не привязывает вас к конкретному способу получения данных, поэтому её легко интегрировать в двунаправленный скролл.
Структура компонента
import { useEffect, useRef, useState, useCallback } from 'react';
import { useVirtual } from '@tanstack/react-virtual';
type Item = { id: string; content: string; height?: number };
export function BiDirectionalScroll() {
const parentRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<Item[]>([]);
const [isFetchingTop, setFetchingTop] = useState(false);
const [isFetchingBottom, setFetchingBottom] = useState(false);
// Виртуализация
const rowVirtualizer = useVirtual({
size: items.length,
parentRef,
estimateSize: useCallback(() => 60, []), // средняя высота строки
overscan: 5,
});
// Загрузка данных при скролле вверх
const loadPrev = async () => {
if (isFetchingTop) return;
setFetchingTop(true);
const newData = await fetchPrevChunk(); // ваш API
// измеряем высоту новых элементов (можно задать фиксированную)
const totalHeight = newData.reduce((sum, it) => sum + (it.height ?? 60), 0);
setItems(prev => [...newData, ...prev]);
// Корректируем scrollTop
if (parentRef.current) {
parentRef.current.scrollTop += totalHeight;
}
setFetchingTop(false);
};
// Загрузка данных при скролле вниз
const loadNext = async () => {
if (isFetchingBottom) return;
setFetchingBottom(true);
const newData = await fetchNextChunk();
setItems(prev => [...prev, ...newData]);
setFetchingBottom(false);
};
// Обработчик скролла
const onScroll = () => {
const el = parentRef.current;
if (!el) return;
// Верхний порог
if (el.scrollTop < 100) {
loadPrev();
}
// Нижний порог
if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
loadNext();
}
};
useEffect(() => {
// Инициализируем первой порцией
loadNext();
}, []);
return (
<div
ref={parentRef}
onScroll={onScroll}
style={{
height: '500px',
overflow: 'auto',
border: '1px solid #ddd',
}}
>
<div
style={{
height: `${rowVirtualizer.totalSize}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.virtualItems.map(virtualRow => {
const item = items[virtualRow.index];
return (
<div
key={item.id}
ref={virtualRow.measureRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
padding: '8px',
boxSizing: 'border-box',
borderBottom: '1px solid #eee',
}}
>
{item.content}
</div>
);
})}
</div>
</div>
);
}
Обработка загрузки данных
Функции fetchPrevChunk и fetchNextChunk должны возвращать массив объектов, упорядоченных от старых к новым. При запросе более старых записей (скролл вверх) важно, чтобы сервер поддерживал пагинацию в обратном порядке. Если API предоставляет только «следующую» страницу, можно хранить текущий курсор и использовать его для обратного запроса.
Оптимизация рендеринга
- Оценка высоты – если строки имеют переменную высоту, включите
measureRefв каждую ячейку.react-virtualавтоматически пересчитываетtotalSize, сохраняя корректную позицию скролла. - Overscan – небольшое количество элементов за пределами видимой зоны (5–10) уменьшает «мигание» при быстрой прокрутке.
- Debounce скролла – в случае интенсивных запросов к серверу применяйте задержку (например,
requestAnimationFrameилиlodash.debounce). - Кеширование – храните уже загруженные порции в состоянии компонента или в глобальном сторе, чтобы повторные переходы к уже просмотренным областям не требовали новых запросов.
Подводные камни и рекомендации
- Смещение scrollTop: при добавлении элементов в начало списка необходимо выполнить коррекцию до того, как браузер отрисует новые строки. Делайте это сразу после
setItems, используяrequestAnimationFrameили синхронный доступ кparentRef.current. - Изменение высоты элементов: если высота строки меняется после загрузки изображений или асинхронных данных, вызовите
rowVirtualizer.measure()для пересчёта размеров. - Проблема «прокрутки к концу»: при постоянном добавлении новых записей в конец (например, в чат‑приложении) пользователь может находиться в «живом» режиме. В этом случае авто‑прокрутка к нижней границе должна срабатывать только если пользователь уже находится внизу; иначе сохраняйте текущий
scrollTop. - SSR: при серверном рендеринге высота виртуального контейнера неизвестна. Можно задать фиксированную высоту первой порции и после монтирования выполнить измерения.
Двунаправленный бесконечный скролл — это не только вопрос UI, но и корректного управления состоянием и измерениями. Сочетание простой арифметики смещения прокрутки и мощных возможностей @tanstack/react-virtual позволяет построить отзывчивый, масштабируемый список, пригодный для чатов, журналов и любых интерфейсов, где требуется навигация как вверх, так и вниз без потери производительности.