DEV Community

reactuse.com
reactuse.com

Posted on • Originally published at reactuse.com

React useInfiniteScroll Hook: Infinite Scrolling Made Simple (2026)

Every feed, every chat log, every search result page eventually asks the same question: how do I load more when the user reaches the bottom? The naive answer — a scroll listener, some arithmetic about scrollHeight and clientHeight, a boolean to prevent double-fetching — is maybe 30 lines, and every one of them is a trap. You forget to clean up the listener. You compare the wrong dimension. You fire the callback on mount before there's anything to scroll. You hard-code "bottom" and then product asks for a chat that loads history upward. You skip throttling and the callback fires 60 times while the user holds the scroll position at the threshold.

useInfiniteScroll from @reactuses/core replaces all of that with one call: point it at a scrollable element, give it a load-more function, and it handles the rest — arrival detection, direction, distance threshold, scroll-position preservation, and cleanup. This post walks the real implementation, the options that matter, and the patterns for feeds, chats, and horizontal carousels. TypeScript-first.

The Simplest Case: Load More at the Bottom

import { useRef, useState } from 'react';
import { useInfiniteScroll } from '@reactuses/core';

function Feed() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [items, setItems] = useState<string[]>(() =>
    Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`)
  );

  useInfiniteScroll(containerRef, async () => {
    const newItems = await fetchMoreItems(items.length);
    setItems(prev => [...prev, ...newItems]);
  });

  return (
    <div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
      {items.map(item => (
        <div key={item} style={{ padding: 16, borderBottom: '1px solid #eee' }}>
          {item}
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. Scroll to the bottom, fetchMoreItems fires. It doesn't fire again until the user scrolls away from the bottom and back. It doesn't fire during SSR. It cleans up the listener on unmount.

The Signature

useInfiniteScroll(target, onLoadMore, options?)
Enter fullscreen mode Exit fullscreen mode
  • target — a ref to the scrollable DOM element (RefObject<Element>).
  • onLoadMore — a function (sync or async) called when the user reaches the scroll edge. It receives the full scroll state from useScroll: [x, y, isScrolling, arrivedState, directions].
  • options — everything useScroll accepts, plus three infinite-scroll-specific fields.

Options That Matter

distance — Fire Before the Edge

useInfiniteScroll(containerRef, loadMore, {
  distance: 200, // fire 200px before hitting the bottom
});
Enter fullscreen mode Exit fullscreen mode

Default is 0. Set distance to preload: at 200, the next page starts fetching while there's still 200 px of content to scroll through.

direction — Not Just Bottom

useInfiniteScroll(containerRef, loadMore, {
  direction: 'top', // load older messages when scrolling up
});
Enter fullscreen mode Exit fullscreen mode

Four directions: 'bottom' (default), 'top', 'left', 'right'. Chat apps want 'top' — the user scrolls up to load history. Horizontal carousels want 'left' or 'right'.

preserveScrollPosition — Stay Where You Were

useInfiniteScroll(containerRef, loadMore, {
  direction: 'top',
  preserveScrollPosition: true,
});
Enter fullscreen mode Exit fullscreen mode

When loading content above the current viewport, the new items push everything down and the user loses their place. preserveScrollPosition: true fixes this: after onLoadMore resolves, the hook adjusts scrollTop by exactly the height of the newly inserted content.

Under the Hood

The implementation is 44 lines:

export const useInfiniteScroll = (target, onLoadMore, options = {}) => {
  const savedLoadMore = useLatest(onLoadMore);
  const direction = options.direction ?? 'bottom';
  const state = useScroll(target, {
    ...options,
    offset: {
      [direction]: options.distance ?? 0,
      ...options.offset,
    },
  });

  const di = state[3][direction]; // arrivedState[direction]

  useUpdateEffect(() => {
    const element = getTargetElement(target);
    const fn = async () => {
      const previous = {
        height: element?.scrollHeight ?? 0,
        width: element?.scrollWidth ?? 0,
      };
      await savedLoadMore.current(state);
      if (options.preserveScrollPosition && element) {
        element.scrollTo({
          top: element.scrollHeight - previous.height,
          left: element.scrollWidth - previous.width,
        });
      }
    };
    fn();
  }, [di, options.preserveScrollPosition, target]);
};
Enter fullscreen mode Exit fullscreen mode

Three pieces make this work:

  1. useScroll does the heavy lifting. It tracks scroll position, arrived state, and direction. The offset option shifts the arrival threshold — useInfiniteScroll maps distance to offset[direction].

  2. useUpdateEffect prevents the mount-fire. It skips the first invocation and only fires when the arrived boolean actually changes.

  3. useLatest kills stale closures. The callback always gets the latest version without recreating the scroll machinery.

Patterns

Chat History (Reverse Scroll)

function ChatHistory({ channelId }: { channelId: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const [messages, setMessages] = useState<Message[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);

  useInfiniteScroll(ref, async () => {
    const data = await fetchMessages(channelId, cursor);
    setMessages(prev => [...data.messages, ...prev]);
    setCursor(data.nextCursor);
  }, {
    direction: 'top',
    preserveScrollPosition: true,
    distance: 100,
  });

  return (
    <div ref={ref} style={{ height: 500, overflow: 'auto' }}>
      {messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the pattern Slack, Discord, and every chat UI uses — and the one that's hardest to get right by hand.

Horizontal Carousel

function HorizontalGallery() {
  const ref = useRef<HTMLDivElement>(null);
  const [images, setImages] = useState<string[]>([]);

  useInfiniteScroll(ref, async () => {
    const moreImages = await fetchImages(images.length);
    setImages(prev => [...prev, ...moreImages]);
  }, {
    direction: 'right',
    distance: 200,
  });

  return (
    <div ref={ref} style={{ display: 'flex', overflowX: 'auto', gap: 16 }}>
      {images.map(src => <img key={src} src={src} style={{ width: 300 }} />)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useInfiniteScroll vs. useIntersectionObserver

  • useIntersectionObserver watches a sentinel element. Works for any container including the window itself and handles complex layouts gracefully.

  • useInfiniteScroll watches the scroll position of a specific container. Simpler to wire up, handles all four directions natively, includes preserveScrollPosition out of the box.

Pick useInfiniteScroll for single-container setups. Pick useIntersectionObserver for window-level loading or complex nested scroll contexts.

SSR Safety

useInfiniteScroll creates no subscriptions during server rendering. useScroll guards on window existence. useUpdateEffect skips the first render entirely. SSR-safe by construction, like every hook in @reactuses/core.

Takeaways

  • One hook replaces the scroll listener, the math, and the cleanup.
  • distance preloads content so the user never waits at the bottom.
  • direction handles all four edges — feeds, chats, carousels.
  • preserveScrollPosition is the chat-history fix.
  • Built on useScroll — throttling, arrived-state tracking, and direction detection for free.
  • SSR-safe with nothing to configure.

Install @reactuses/core, point useInfiniteScroll at your list container, and stop writing scroll arithmetic by hand.


This post is part of the ReactUse hook guide series. The library ships 100+ hooks for browser APIs, state, effects, and elements — all SSR-safe, TypeScript-first, and tested.

Top comments (0)