Как сделать двунаправленный бесконечный скролл в React
- четверг, 12 марта 2026 г. в 00:00:04
Большинство туториалов по бесконечному скроллу покрывают только одно направление: вниз. Ловим конец списка, подгружаем, готово. Но в реальных приложениях нужен скролл в обе стороны: история чата, лог-вьюеры, таймлайны. А скролл вверх создаёт проблему, которой при скролле вниз просто нет.
В этом гайде я покажу, как собрать двунаправленный бесконечный скролл с нуля. Здесь React и @tanstack/react-virtual, но сама техника — просто математика над scroll offset. Работает так же в Vue, Svelte или на ванильном JS.
Список из 1000 элементов. Пользователь смотрит на элемент #50. Ты добавляешь 200 элементов сверху.
Что ожидаешь: пользователь по-прежнему видит элемент #50.
Что на самом деле: scroll position остаётся на том же пиксельном смещении. Но элемент #50 теперь на другом смещении (сместился вниз на высоту 200 новых элементов). Пользователь видит элемент #250. Контент прыгнул.
ДО PREPEND ПОСЛЕ PREPEND (сломано) ┌─────────────┐ ┌─────────────┐ │ item 48 │ │ item 248 ←── что? │ item 49 │ │ item 249 │ │ item 50 ◄──│── юзер │ item 250 │ │ item 51 │ видит │ item 251 │ │ item 52 │ это │ item 252 │ └─────────────┘ └─────────────┘ scrollTop: 2200px scrollTop: 2200px (тот же!) а item 50 теперь на 11000px
Виртуализация, загрузка данных, рендеринг — всё стандартно. Починить прыжок — единственная неочевидная часть.
React + TypeScript + Vite
@tanstack/react-virtual (рендерит только видимые элементы, важно для 1000+ строк)
Tailwind CSS
Ещё я добавил react-chartjs-2 для bar-чарта, синхронизированного со скроллом, но это отдельно от логики скролла.
Нужен источник данных, который умеет загружать в обе стороны. В реальном приложении это был бы API. Для демо генерирую моковые лог-события:
export function useLogData() { const [days, setDays] = useState<DayData[]>(() => generateDays(startDate, 30)); const prependCountRef = useRef(0); const loadEarlier = useCallback(() => { setDays(prev => { const newDays = generateDays(earlierDate, 15); // Запоминаем, сколько элементов добавим сверху prependCountRef.current = newDays.reduce( (sum, d) => sum + d.events.length, 0 ); return [...newDays, ...prev]; }); }, []); const loadLater = useCallback(() => { setDays(prev => [...prev, ...generateDays(laterDate, 15)]); }, []); return { days, allEvents, loadEarlier, loadLater, prependCountRef }; }
prependCountRef хранит количество только что добавленных сверху элементов. Понадобится через минуту.
С @tanstack/react-virtual рендерим только ~20 видимых элементов из тысяч:
const virtualizer = useVirtualizer({ count: events.length, getScrollElement: () => parentRef.current, estimateSize: () => 44, // примерная высота строки в px overscan: 10, // доп. элементы выше/ниже viewport });
Scroll-контейнер содержит высокий пустой div (общая высота всех элементов), а внутри — только видимые элементы, спозиционированные через transform: translateY(). Стандартная виртуализация.
На каждый скролл проверяем, не подъехал ли пользователь к краю:
const handleScroll = useCallback(() => { const items = virtualizer.getVirtualItems(); if (items.length === 0) return; const firstVisible = items[0]; const lastVisible = items[items.length - 1]; // Близко к верху? Загружаем старые данные if (firstVisible.index <= 5) { loadEarlier(); } // Близко к низу? Загружаем новые данные if (lastVisible.index >= events.length - 5) { loadLater(); } }, [virtualizer]);
loadLater (дозагрузка вниз) просто работает. Virtualizer видит больше элементов, увеличивает высоту контейнера, пользователь скроллит дальше.
loadEarlier (дозагрузка вверх) ломает всё. Тут и происходит прыжок.
После prepend сдвигаем scroll position вниз ровно на высоту добавленных элементов:
useEffect(() => { const prepended = prependCountRef.current; if (prepended > 0 && events.length > prevCountRef.current) { const currentOffset = virtualizer.scrollOffset ?? 0; const addedHeight = prepended * 44; // элементы × estimateSize virtualizer.scrollToOffset(currentOffset + addedHeight, { align: 'start' }); prependCountRef.current = 0; } prevCountRef.current = events.length; }, [events.length]);
ДО PREPEND ПОСЛЕ PREPEND (починено) ┌─────────────┐ ┌─────────────┐ │ item 48 │ │ item 48 │ ← то же! │ item 49 │ │ item 49 │ │ item 50 ◄──│── юзер │ item 50 ◄──│── всё ещё тут │ item 51 │ видит │ item 51 │ │ item 52 │ это │ item 52 │ └─────────────┘ └─────────────┘ scrollTop: 2200px scrollTop: 11000px (скорректирован!)
Пользователь ничего не замечает. 200 новых элементов загрузились выше viewport.
Почему ref, а не state? prependCountRef записывается внутри setDays (во время обновления state) и читается в useEffect (после обновления). Ref связывает эти два момента без лишнего ре-рендера.
Если строки раскрываются (клик по лог-записи показывает детали), virtualizer должен знать реальную высоту, а не оценочную:
export const LogItem = memo(function LogItem({ event, virtualIndex, measureRef, start }) { const [expanded, setExpanded] = useState(false); const nodeRef = useRef<HTMLDivElement | null>(null); const setRef = useCallback((node: HTMLDivElement | null) => { nodeRef.current = node; measureRef(node); // говорим virtualizer измерить этот узел }, [measureRef]); // Перемеряем ДО отрисовки при expand/collapse useLayoutEffect(() => { if (nodeRef.current) measureRef(nodeRef.current); }, [expanded]); return ( <div ref={setRef} data-index={virtualIndex} style={{ transform: `translateY(${start}px)` }}> {/* содержимое строки */} {expanded && <pre>{JSON.stringify(event.details, null, 2)}</pre>} </div> ); });
На что обратить внимание:
data-index — так @tanstack/react-virtual определяет, какому виртуальному элементу принадлежит DOM-узел. Без него measureElement не знает, какую строку измеряет.
useLayoutEffect, а не useEffect. useEffect запускается после отрисовки — пользователь увидит один кадр, где раскрытый контент наезжает на следующую строку. useLayoutEffect запускается до отрисовки, измерение происходит незаметно.
Скроллим вниз — подгружаются новые дни. Скроллим вверх — подгружаются старые, без прыжков. Кликаем по столбцу графика — список прокручивается к этому дню. Раскрываем лог-запись — строки ниже сдвигаются корректно.
Стартуем с ~2000 элементов, растём бесконечно в обе стороны. Virtualizer держит ~20-30 DOM-нод вне зависимости от общего числа.
Вся техника — две строчки:
const addedHeight = prependedCount * estimatedRowHeight; virtualizer.scrollToOffset(currentOffset + addedHeight, { align: 'start' });
Запоминаешь, сколько элементов добавил сверху. После prepend — прибавляешь их высоту к scroll offset.