useInfiniteQuery 分页与无限滚动

TanStack Query 的 useInfiniteQuery 实现无限滚动和分页加载,支持双向加载、自定义分页参数、与虚拟化列表集成。

#type / howto #status / evergreen #tech / dev / frontend #resource / react

[!info] related notes

useInfiniteQuery 分页与无限滚动

核心问题

传统分页需要用户点”上一页/下一页”。无限滚动(如社交媒体 feed)需要用户滚动时自动加载更多。

两种场景的共同需求:

  • 保留已加载的数据(不是替换)
  • 知道”还有没有下一页”
  • 处理加载中、加载失败、没有更多数据的状态

useInfiniteQuery 基础

基本用法

import { useInfiniteQuery } from '@tanstack/react-query';

function Feed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor,  // 返回下一页的参数,undefined 表示没有更多
  });

  if (isLoading) return <Loading />;
  if (isError) return <Error />;

  // data.pages 是所有页的数组
  const allPosts = data.pages.flatMap(page => page.posts);

  return (
    <div>
      {allPosts.map(post => <PostCard key={post.id} post={post} />)}

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? '加载中...' : '加载更多'}
        </button>
      )}

      {!hasNextPage && <p>没有更多了</p>}
    </div>
  );
}

数据结构

data = {
  pages: [
    { posts: [...], nextCursor: 'abc' },   // 第 1 页
    { posts: [...], nextCursor: 'def' },   // 第 2 页
    { posts: [...], nextCursor: undefined }, // 第 3 页(最后一页)
  ],
  pageParams: [undefined, 'abc', 'def'],    // 每次请求用的 pageParam
}

分页参数模式

偏移量分页(offset-based)

useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts({ offset: pageParam, limit: 20 }),
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages) => {
    const nextOffset = allPages.length * 20;
    return nextOffset < lastPage.total ? nextOffset : undefined;
  },
});

游标分页(cursor-based)

useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});

页码分页(page-based)

useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts({ page: pageParam }),
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) => {
    return allPages.length < lastPage.totalPages ? allPages.length + 1 : undefined;
  },
});

双向加载

聊天场景需要向上翻页加载历史消息:

useInfiniteQuery({
  queryKey: ['messages', conversationId],
  queryFn: ({ pageParam, direction }) => {
    if (direction === 'forward') {
      return fetchNewerMessages({ after: pageParam });
    }
    return fetchOlderMessages({ before: pageParam });
  },
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage) => firstPage.previousCursor,
});

// 向上加载历史
const { fetchPreviousPage, hasPreviousPage, isFetchingPreviousPage } = query;

自动加载(Intersection Observer)

import { useRef, useEffect } from 'react';

function InfiniteList() {
  const loadMoreRef = useRef(null);

  const { fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
    initialPageParam: undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  // Intersection Observer 自动触发
  useEffect(() => {
    if (!hasNextPage || isFetchingNextPage) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );

    if (loadMoreRef.current) {
      observer.observe(loadMoreRef.current);
    }

    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  // ...
  return <div ref={loadMoreRef}>{/* 触发元素 */}</div>;
}

与虚拟化列表集成

大数据量时用虚拟化避免 DOM 过多:

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedFeed() {
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
    initialPageParam: undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: allPosts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    overscan: 5,
  });

  // 滚动到底部时加载更多
  useEffect(() => {
    const lastItem = virtualizer.getVirtualItems().at(-1);
    if (lastItem && lastItem.index >= allPosts.length - 1 && hasNextPage) {
      fetchNextPage();
    }
  }, [virtualizer.getVirtualItems(), hasNextPage, fetchNextPage]);

  return (
    <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              height: virtualRow.size,
              width: '100%',
            }}
          >
            <PostCard post={allPosts[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

刷新与失效

刷新整个列表

queryClient.invalidateQueries({ queryKey: ['posts'] });
// 会重新请求第一页,丢弃所有已加载的页

只刷新某一页的数据

// 通常不需要,因为 infinite query 的数据是累积的
// 如果需要更新单个帖子,用 setQueryData 更新整个 pages 数组
queryClient.setQueryData(['posts'], (old) => {
  if (!old) return old;
  return {
    ...old,
    pages: old.pages.map(page => ({
      ...page,
      posts: page.posts.map(p =>
        p.id === updatedPost.id ? updatedPost : p
      ),
    })),
  };
});

常见错误

getNextPageParam 返回错误类型

// ❌ 返回 null 而不是 undefined
getNextPageParam: (lastPage) => lastPage.nextCursor || null,
// TanStack Query 认为 null 是有效参数,会继续请求

// ✅ 没有下一页时返回 undefined
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,

忘记 initialPageParam

// ❌ v5 必须提供 initialPageParam
useInfiniteQuery({
  queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  // 缺少 initialPageParam → 类型错误
});

// ✅ 明确初始值
useInfiniteQuery({
  queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
  initialPageParam: undefined,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
});

数据展平方式错误

// ❌ 忘记展平
const posts = data?.pages;  // 这是页数组,不是帖子数组

// ✅ 正确展平
const posts = data?.pages.flatMap(page => page.posts) ?? [];

useInfiniteQuery vs useQuery 分页

特性useQuery + page stateuseInfiniteQuery
已加载数据每次只保留当前页保留所有已加载的页
用户体验点击翻页滚动加载 / 点击加载
内存只存一页存所有页(大数据量需虚拟化)
适用场景表格分页、搜索结果Feed、聊天记录、长列表
创建于 2026/7/3 更新于 2026/7/15