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:
@tanstack/react-virtualfor virtualizationuseInfiniteQueryfrom@tanstack/react-queryto load messages
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.