2

I’m building a chat UI in Next.js (App Router) and struggling with maintaining the scroll position when fetching older messages at the top.

I’m using:


The behavior I want

  • When the user scrolls to the top, I fetch the previous page of messages
  • The new messages prepend correctly
  • But I want the scroll position to stay stable, so the user doesn’t feel a “jump”

Here’s what I tried (TypeScript)

// Scroll detection for infinite loading at top
const fetchMoreOnTopReached = useCallback(
  (containerRefElement?: HTMLDivElement | null) => {
    if (!onScrollToTop || isFetchingMore || !containerRefElement) return;

    // Check if we've reached the top threshold
    if (
      isScrollThresholdReached({
        container: containerRefElement,
        direction: ScrollDirection.TOP,
        threshold: SCROLL_THRESHOLD_PX,
      })
    ) {
      // ✅ Take snapshot RIGHT HERE before triggering the fetch
      previousScrollPositionFromBottomRef.current =
        getScrollPositionFromBottom();

      console.log("📸 Snapshot taken at threshold:", {
        scrollHeight: containerRefElement.scrollHeight,
        positionFromBottom: previousScrollPositionFromBottomRef.current,
      });

      onScrollToTop();
    }
  },
  [onScrollToTop, isFetchingMore]
);

const getScrollPositionFromBottom = useCallback(() => {
  const container = scrollContainerRef.current;
  if (!container) return 0;

  const { scrollTop, scrollHeight, clientHeight } = container;
  return scrollHeight - scrollTop - clientHeight;
}, [scrollContainerRef]);

// Debug logging to inspect scroll behavior
useEffect(() => {
  setInterval(() => {
    const container = scrollContainerRef.current;
    if (!container) return;
    console.log("🔄 Scroll height:", container.scrollHeight);
    console.log("🔄 Scroll position from bottom:", getScrollPositionFromBottom());
  }, 1000);
}, [scrollContainerRef, getScrollPositionFromBottom]);

// Restore scroll position after messages are prepended
useLayoutEffect(() => {
  const container = scrollContainerRef.current;
  if (!container) return;

  if (
    isFetchingMore === false &&
    previousScrollPositionFromBottomRef.current > 0
  ) {
    const { scrollHeight, clientHeight } = container;
    console.log("🔄 Restoring scroll height:", scrollHeight);

    const newScrollTop =
      scrollHeight -
      previousScrollPositionFromBottomRef.current -
      clientHeight;

    console.log("🔄 Restoring scroll position:", {
      newScrollTop,
      positionFromBottom: previousScrollPositionFromBottomRef.current,
    });

    // Wait for the virtualizer to re-measure
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        virtualizer.scrollToOffset(newScrollTop, {
          align: "start",
          behavior: "auto",
        });
      });
    });

    // Reset ref
    previousScrollPositionFromBottomRef.current = 0;
  }
}, [isFetchingMore, getScrollPositionFromBottom, virtualizer]);

This almost works, but it still results in a small scroll jump depending on how fast the virtualizer recalculates item heights.


Has anyone found a way to handle “infinite scroll from top” with TanStack Virtual, while keeping the scroll position stable?

Any code examples, timing strategies, or helper utilities/libraries that make this easier would be amazing.

1 Answer 1

0

I had the same problem. The only solution I found is - https://stackblitz.com/edit/tanstack-query-xrw3fp

You need to look to this bunch of code. Because only in this state, without useEffects e.t.c it works. This code saves the offset when user scrolls up

  const firstId = data?.pages[0].nextId ?? 0;
  const addedToTheBeginning = firstId < prevId;
  prevId = firstId;

  if (addedToTheBeginning) {
    const offset =
      virtualizer.scrollOffset + (data?.pages[0].data ?? []).length * itemSize;
    virtualizer.scrollOffset = offset;
    virtualizer.calculateRange();
  }

I hope it will help you, I was trying to reach case like your for 3-4 weeks. If you don't wanna use tanstack/react-virtual you can check react-virtuoso, but some components are paid

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.