Большинство туториалов по бесконечному скроллу покрывают только одно направление: вниз. Ловим конец списка, подгружаем, готово. Но в реальных приложениях нужен скролл в обе стороны: история чата, лог-вьюеры, таймлайны. А скролл вверх создаёт проблему, которой при скролле вниз просто нет.

В этом гайде я покажу, как собрать двунаправленный бесконечный скролл с нуля. Здесь 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-чарта, синхронизированного со скроллом, но это отдельно от логики скролла.

Шаг 1: хук для данных

Нужен источник данных, который умеет загружать в обе стороны. В реальном приложении это был бы 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 хранит количество только что добавленных сверху элементов. Понадобится через минуту.

Шаг 2: виртуализированный список

С @tanstack/react-virtual рендерим только ~20 видимых элементов из тысяч:

const virtualizer = useVirtualizer({  count: events.length,  getScrollElement: () => parentRef.current,  estimateSize: () => 44,  // примерная высота строки в px  overscan: 10,            // доп. элементы выше/ниже viewport
});

Scroll-контейнер содержит высокий пустой div (общая высота всех элементов), а внутри — только видимые элементы, спозиционированные через transform: translateY(). Стандартная виртуализация.

Шаг 3: загрузка в обе стороны

На каждый скролл проверяем, не подъехал ли пользователь к краю:

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 (дозагрузка вверх) ломает всё. Тут и происходит прыжок.

Шаг 4: чиним прыжок — scroll anchoring

После 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 связывает эти два момента без лишнего ре-рендера.

Шаг 5: динамическая высота строк

Если строки раскрываются (клик по лог-записи показывает детали), 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>  );
});

На что обратить внимание:

  1. data-index — так @tanstack/react-virtual определяет, какому виртуальному элементу принадлежит DOM-узел. Без него measureElement не знает, какую строку измеряет.

  2. useLayoutEffect, а не useEffect. useEffect запускается после отрисовки — пользователь увидит один кадр, где раскрытый контент наезжает на следующую строку. useLayoutEffect запускается до отрисовки, измерение происходит незаметно.

Результат

Скроллим вниз — подгружаются новые дни. Скроллим вверх — подгружаются старые, без прыжков. Кликаем по столбцу графика — список прокручивается к этому дню. Раскрываем лог-запись — строки ниже сдвигаются корректно.

Стартуем с ~2000 элементов, растём бесконечно в обе стороны. Virtualizer держит ~20-30 DOM-нод вне зависимости от общего числа.

Попробуй демо | Исходный код

TL;DR

Вся техника — две строчки:

const addedHeight = prependedCount * estimatedRowHeight;
virtualizer.scrollToOffset(currentOffset + addedHeight, { align: 'start' });

Запоминаешь, сколько элементов добавил сверху. После prepend — прибавляешь их высоту к scroll offset.