2026年8月3日

React useInfiniteScroll Hook:無限捲動輕鬆實現(2026)

每個資訊流、每個聊天記錄、每個搜尋結果頁面最終都會問同一個問題:使用者捲到底部時怎麼載入更多? 樸素的答案——一個 scroll 監聽器、一些關於 scrollHeightclientHeight 的算術、一個防止重複請求的布林值——大約 30 行程式碼,而每一行都是陷阱。你忘了清理監聽器。你比較了錯誤的尺寸。你在 mount 時觸發了回呼,那時候根本沒有內容可以捲動。你硬編碼了「底部」,然後產品要求做一個向上載入歷史記錄的聊天介面。你沒做節流,於是使用者把捲動位置停在閾值附近時回呼每秒觸發 60 次。

@reactuses/coreuseInfiniteScroll 用一次呼叫替代了所有這些:把它指向一個可捲動元素,給它一個載入更多函式,剩下的它全包了——到達偵測、方向、距離閾值、捲動位置保持和清理。這篇文章會走讀真實實作、關鍵選項,以及資訊流、聊天和水平輪播的實戰模式。TypeScript 優先。

最簡用法:捲到底部載入更多

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>
  );
}

就這麼多。捲到底部,fetchMoreItems 觸發。使用者捲走再捲回來之前不會重複觸發。SSR 期間不會觸發。卸載時自動清理監聽器。容器可以是任何可捲動元素——一個帶 overflow: autodiv、一個 <section>,只要 ref 指向它就行。

函式簽名

useInfiniteScroll(target, onLoadMore, options?)
  • target — 可捲動 DOM 元素的 ref(RefObject<Element>)。
  • onLoadMore — 使用者到達捲動邊緣時呼叫的函式(同步或非同步)。它接收來自 useScroll 的完整捲動狀態:[x, y, isScrolling, arrivedState, directions]
  • optionsuseScroll 接受的所有選項,加上三個無限捲動專屬欄位。

關鍵選項

distance —— 提前觸發

useInfiniteScroll(containerRef, loadMore, {
  distance: 200, // 距底部 200px 時就觸發
});

預設值是 0——只有捲到絕對邊緣才觸發回呼。設定 distance 可以預載入:設為 200 時,在還有 200 px 內容可捲動的時候就開始請求下一頁,這樣網速夠快的話使用者永遠看不到載入中。合適的數值取決於列表項高度和請求延遲——從一屏高度開始,往下調。

direction —— 不只是底部

useInfiniteScroll(containerRef, loadMore, {
  direction: 'top', // 向上捲動時載入更早的訊息
});

四個方向:'bottom'(預設)、'top''left''right'。聊天應用要 'top'——使用者向上捲動載入歷史訊息。水平輪播要 'left''right'。hook 會自動把到達偵測連接到正確的邊緣。

preserveScrollPosition —— 留在原地

useInfiniteScroll(containerRef, loadMore, {
  direction: 'top',
  preserveScrollPosition: true,
});

當你在當前視口上方載入內容時(聊天歷史、倒序資訊流),新內容會把所有東西往下推,使用者就遺失了位置。preserveScrollPosition: true 解決了這個問題:onLoadMore resolve 之後,hook 會把 scrollTop(水平方向則是 scrollLeft)精確偏移新插入內容的高度(或寬度)。使用者看到的捲動位置不變,更早的訊息出現在上方。

throttle —— 繼承自 useScroll

useInfiniteScroll(containerRef, loadMore, {
  throttle: 100, // 每 100ms 至多偵測一次到達
});

這是 useScroll 的選項,useInfiniteScroll 直接透傳。它節流底層的捲動事件處理器——當你的容器以 120 fps 捲動而你不需要亞幀級到達偵測時很有用。

底層實作

實作只有 44 行。它做了這些事:

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]);
};

三個關鍵部分讓它運作:

  1. useScroll 做了所有重活。 它追蹤 xyisScrolling、到達狀態(四個邊緣各一個布林值)和捲動方向。offset 選項移動到達閾值——useInfiniteScroll 把它的 distance 選項映射到 offset[direction],所以「到達底部」實際上是「到達距底部 distance 像素以內」。

  2. useUpdateEffect 防止了 mount 時觸發。 普通 useEffect 會在 mount 時呼叫 onLoadMore——那時容器還沒有任何內容可捲動。useUpdateEffect 跳過首次呼叫,只在 di(所選方向的到達布林值)實際變化時才觸發。回呼在每次到達時觸發一次,而不是每次捲動事件觸發一次。

  3. useLatest 消滅了閉包過期。 onLoadMore 回呼大機率閉包了渲染間會變化的狀態——當前頁碼、已累積的條目、游標。useLatest 把它包在 ref 裡,所以呼叫的始終是最新版本,而無需重建捲動機制。

preserveScrollPosition 的技巧

onLoadMore resolve 之後(新條目已經在 DOM 裡了),hook 快照 scrollHeight/scrollWidth變化量,然後呼叫 element.scrollTo() 精確偏移那個差值。這是一個非同步操作之後的同步 DOM 測量——它能運作是因為 onLoadMore 中的 React 狀態更新在 await 恢復時已經刷新到 DOM 了。

實戰模式

分頁資訊流

function PaginatedFeed() {
  const ref = useRef<HTMLDivElement>(null);
  const [page, setPage] = useState(1);
  const [items, setItems] = useState<Item[]>([]);
  const [hasMore, setHasMore] = useState(true);

  useInfiniteScroll(ref, async () => {
    if (!hasMore) return;
    const data = await fetchPage(page);
    setItems(prev => [...prev, ...data.items]);
    setHasMore(data.hasNextPage);
    setPage(prev => prev + 1);
  }, { distance: 300 });

  return (
    <div ref={ref} style={{ height: '100vh', overflow: 'auto' }}>
      {items.map(item => <Card key={item.id} item={item} />)}
      {!hasMore && <p>沒有更多了</p>}
    </div>
  );
}

hasMore 做守衛,API 說沒有更多資料時回呼變成空操作。hook 在邊緣仍然會觸發——守衛讓觸發的代價很低。

聊天歷史(反向捲動)

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>
  );
}

direction: 'top' 在使用者捲到頂部時觸發。preserveScrollPosition: true 在舊訊息前插之後保持視口停在同一條訊息上。這就是 Slack、Discord 和所有聊天 UI 用的模式——也是手寫最容易翻車的模式,因為捲動位置的計算必須在 DOM 更新之後、瀏覽器繪製之前執行。

水平輪播

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>
  );
}

同一個 hook,不同的軸。direction: 'right' 監視 scrollLeft 相對於 scrollWidth 的位置。

useInfiniteScroll vs. useIntersectionObserver

兩者都能觸發「載入更多」。區別在於它們監視什麼:

  • useIntersectionObserver 監視一個哨兵元素——列表底部的一個 div。當哨兵進入視口時,載入更多。它適用於任何容器,包括 window 本身,並且能優雅地處理複雜佈局(黏性頭部、巢狀捲動容器),因為瀏覽器的交叉計算會考量所有這些因素。

  • useInfiniteScroll 監視特定容器的捲動位置。連接更簡單(不需要管理哨兵元素),原生支援四個方向,並且內建 preserveScrollPosition

在以下情況選 useInfiniteScroll:你有一個單獨的可捲動容器,想要最簡單的設定。在以下情況選 useIntersectionObserver:你在 window 層級載入、有複雜的巢狀捲動上下文,或者需要對觸發閾值做精細控制。

捲動家族

  • useScroll —— 基石:追蹤任何可捲動元素的 xyisScrolling、到達狀態和方向。useInfiniteScroll 基於它建構。
  • useWindowScroll —— 同樣的追蹤,但專門針對 window
  • useThrottle / useDebounce —— 對任何值做速率限制。useScroll 內建了 throttle 支援,但如果你因為其他原因需要節流載入更多的輸出,這兩個就是你的工具。
  • useElementSize —— 如果你需要知道容器的尺寸來計算每頁該請求多少條目。

SSR 安全

useInfiniteScroll 在伺服器渲染期間不建立任何訂閱。捲動監聽器在 useScroll 內部附加,而後者會檢查 window 是否存在。useUpdateEffect 完全跳過首次渲染。在伺服器端,這個 hook 是一個不觸碰任何瀏覽器全域變數的空操作——你的 Next.js / Remix 建構渲染初始條目並乾淨地 hydrate,無限捲動隨客戶端一起醒來。和 @reactuses/core 的每個 hook 一樣,在建構上就是 SSR 安全的。

要點總結

  • 一個 hook 替代了捲動監聽器、計算和清理。 useInfiniteScroll 接收一個 ref 和一個回呼,其餘全搞定。
  • distance 預載入內容,讓使用者永遠不用在底部等待。
  • direction 處理全部四個邊緣 —— 'bottom' 用於資訊流,'top' 用於聊天歷史,'left'/'right' 用於輪播。
  • preserveScrollPosition 是聊天歷史的救星 —— 前插內容後調整捲動偏移,讓視口不跳動。
  • 基於 useScroll 建構,意味著你免費獲得了節流、到達狀態追蹤和方向偵測。
  • useUpdateEffect 防止了 mount 時觸發 —— 回呼在使用者實際捲動到邊緣之前不會執行。
  • SSR 安全,無需設定 —— 客戶端接管之前沒有監聽器,沒有瀏覽器全域變數。

安裝 @reactuses/core,把 useInfiniteScroll 指向你的列表容器,告別手寫捲動算術。