React Native TanStack Query v5 数据获取完全指南:离线缓存、Suspense 与无限滚动实战(2026)

学习如何在 React Native 中配置 TanStack Query v5:AppState 集成、NetInfo 离线支持、MMKV 持久化、useInfiniteQuery 与 Suspense 实战代码示例完整可复制。

React Native TanStack Query v5 指南 (2026)

最后更新:2026年6月12日

在 React Native 中使用 TanStack Query v5 是 2026 年处理服务端数据最推荐的方案。它把请求、缓存、重试、后台刷新、离线持久化和 Suspense 集成都封装在 useQueryuseMutation 两个钩子里,开箱即用,并且自动处理 React Native 特有的 AppState 切换与网络状态变化。本指南面向已经熟悉 React Web 的开发者,把"从 React Query 迁移到 React Native 时需要重新理解的部分"一次讲清楚,包括 NetInfo 集成、MMKV 持久化、无限滚动与 Suspense 实战。

  • TanStack Query v5(截至 2026 年 6 月稳定版 5.62.x)原生支持 React Native,无需额外的 RN 专用包。
  • 必须用 focusManager 替换浏览器的 window focus 监听,改为监听 AppState,否则前后台切换不会自动重新获取数据。
  • onlineManager 配合 @react-native-community/netinfo,可以让 TanStack Query 在断网时暂停请求、联网时自动恢复并重试失败的 mutation。
  • 结合 react-native-mmkv 的同步持久化器,应用冷启动可以瞬间复原上次的查询缓存,几乎看不到 loading 状态。
  • useInfiniteQuery 配合 FlashList v2 是实现无限滚动列表的标准组合,支持双向分页。
  • React Native 0.78+ 在 Bridgeless 模式下完整支持 React 19 Suspense,可以用 useSuspenseQuery 让 ErrorBoundary 与加载边界声明式化。

为什么 React Native 项目要选 TanStack Query v5

说实话,从 Web 转到 RN 的开发者最容易踩的坑,是把"服务端数据"塞进 Redux 或 Zustand 这种客户端状态容器里,然后亲手实现缓存失效、重试、并发去重、AppState 重新获取、断网重试、节流等等十几个机制。TanStack Query v5 把这些全部内置,并且没有任何针对客户端框架的硬绑定。官方文档专门有 React Native 章节说明如何接入 AppState 与 NetInfo。

对比之下,纯 fetch + useEffect 写法在 RN 里几乎总是错的。组件每次重渲染会重新触发请求,AppState 切换后数据可能已经过期,断网时请求直接失败而不是排队,并且没有任何并发去重(同一屏幕的两个组件请求同一个接口会发两次)。v5 相对 v4 的关键变化是默认开启 structuralSharingqueryKey 必须是数组、移除了回调式 onSuccess/onError(改用 useEffectuseMutation 的回调),所以从旧文档抄代码时务必核对版本。

我在自己的项目里把 18 个 useEffect + useState 数据获取替换成 TanStack Query 后,代码量减少约 60%,AppState 与冷启动相关的 bug 一次性归零。这就是它值得作为默认选择的原因。

安装与 QueryClient 初始化

v5 不再需要任何 React Native 专用包,直接安装核心库即可。下面是面向 Expo SDK 53 / React Native 0.78 的标准安装。

# Expo 项目
npx expo install @tanstack/react-query @tanstack/react-query-devtools \
  @react-native-community/netinfo react-native-mmkv

# 纯裸 React Native 项目
npm install @tanstack/react-query @tanstack/react-query-devtools \
  @react-native-community/netinfo react-native-mmkv
cd ios && pod install

接着在应用根组件创建 QueryClient。注意 QueryClient 必须放在组件外或 useState 里以避免每次重渲染都新建实例(这是 v5 文档里反复强调的一点)。

// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export default function App() {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            // 移动端流量较贵,默认 5 分钟内不重新获取
            staleTime: 1000 * 60 * 5,
            // 失败时使用指数退避,最多重试 2 次
            retry: 2,
            retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
          },
          mutations: { retry: 1 },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {/* 路由、主题、其他 Provider */}
    </QueryClientProvider>
  );
}

用 AppState 替换浏览器 focus 事件

这是从 React Web 迁移过来最关键的一步。Web 版本的 TanStack Query 通过 window.addEventListener('focus', ...) 实现切回标签页时自动重新获取数据,但 React Native 没有 window,必须改用 AppState 模块。官方 AppState 文档会告诉你 activebackgroundinactive 三种状态的语义。

在应用入口(App.tsx_layout.tsx)里加入:

// lib/queryFocus.ts
import { focusManager } from '@tanstack/react-query';
import { AppState, AppStateStatus, Platform } from 'react-native';

function onAppStateChange(status: AppStateStatus) {
  // web 上 focusManager 已自动接管,原生平台才需要桥接
  if (Platform.OS !== 'web') {
    focusManager.setFocused(status === 'active');
  }
}

export function setupQueryFocus() {
  const subscription = AppState.addEventListener('change', onAppStateChange);
  return () => subscription.remove();
}
// App.tsx
import { useEffect } from 'react';
import { setupQueryFocus } from './lib/queryFocus';

export default function App() {
  useEffect(() => setupQueryFocus(), []);
  // …
}

设置好之后,用户把 App 切到后台 10 分钟再回来,所有过期(超过 staleTime)的查询会自动在后台重新获取,但屏幕上仍然先显示旧数据。这正是用户期待的"立即响应 + 静默更新"行为。如果你不做这步,会发现下拉刷新永远是用户唯一的更新途径,是从 Web 迁移最常见的体验回退。

离线支持:NetInfo 与 onlineManager

移动设备会频繁切换 Wi-Fi、4G、电梯、地铁,断网期间发起的请求应该排队等候而不是失败。TanStack Query 的 onlineManager 配合 NetInfo 可以实现真正的"断网暂停、联网恢复"。

// lib/queryOnline.ts
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';

export function setupQueryOnline() {
  return onlineManager.setEventListener((setOnline) =>
    NetInfo.addEventListener((state) => {
      setOnline(!!state.isConnected && state.isInternetReachable !== false);
    })
  );
}

这里要特别注意 isInternetReachable 这一项。isConnected: true 只表示物理上接入网络,但连了一个没有出网的酒店 Wi-Fi 时实际上拉不到数据。同时判断两者才是稳妥写法(这个细节我上次发版当天被用户投诉才发现)。

关于状态管理与服务端缓存的边界,可以参考之前那篇 Zustand v5 + MMKV 状态管理实战。Zustand 管 UI 状态,TanStack Query 管服务端数据,两者各管一块。

用 MMKV 持久化查询缓存

v5 的 persistQueryClient 默认会用 localStorage,但 RN 没有 localStorage,并且 AsyncStorage 是异步的、有 6MB 限制、性能很差。正确做法是用 react-native-mmkv,它基于腾讯 MMKV,同步读取、纳秒级性能、无大小硬限。

// lib/queryPersister.ts
import { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV({ id: 'tanstack-query-cache' });

export const mmkvPersister: Persister = {
  persistClient: async (client: PersistedClient) => {
    storage.set('react-query-cache', JSON.stringify(client));
  },
  restoreClient: async () => {
    const value = storage.getString('react-query-cache');
    return value ? (JSON.parse(value) as PersistedClient) : undefined;
  },
  removeClient: async () => {
    storage.delete('react-query-cache');
  },
};
// App.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { mmkvPersister } from './lib/queryPersister';

<PersistQueryClientProvider
  client={queryClient}
  persistOptions={{
    persister: mmkvPersister,
    maxAge: 1000 * 60 * 60 * 24, // 缓存最多 24 小时
    buster: 'v1', // App 版本升级时改这里失效全部缓存
  }}
>
  {children}
</PersistQueryClientProvider>

有了这套配置,用户冷启动时会瞬间看到上次的列表数据,TanStack Query 同时在后台请求最新数据并自动替换。体验上接近原生 App 的"先显示后刷新"效果。如果对持久化机制本身想深入了解,推荐先看上面提到的 MMKV 持久化完整指南。

乐观更新与失败回滚

"点赞、收藏、删除"这类操作的最佳体验,是 UI 立即反映成功状态,等服务器确认;失败则回滚。v5 的 useMutationonMutate + onError + onSettled 三步实现这个模式。

// hooks/useToggleLike.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

type Post = { id: string; liked: boolean; likeCount: number };

export function useToggleLike(postId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (next: boolean) => {
      const res = await fetch(`/api/posts/${postId}/like`, {
        method: next ? 'POST' : 'DELETE',
      });
      if (!res.ok) throw new Error('toggle failed');
      return res.json();
    },
    onMutate: async (next) => {
      // 取消正在进行的查询,避免覆盖乐观更新
      await qc.cancelQueries({ queryKey: ['post', postId] });
      const previous = qc.getQueryData<Post>(['post', postId]);
      qc.setQueryData<Post>(['post', postId], (old) =>
        old ? { ...old, liked: next, likeCount: old.likeCount + (next ? 1 : -1) } : old
      );
      return { previous };
    },
    onError: (_err, _next, ctx) => {
      // 失败时回滚
      if (ctx?.previous) qc.setQueryData(['post', postId], ctx.previous);
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ['post', postId] });
    },
  });
}

从 v4 迁移的开发者要特别注意:v5 移除了 useMutation 顶层的 onSuccess/onError 直接副作用调用。回调还在,但只在 mutation 生命周期内执行,组件卸载后不会触发。我上一个项目就是踩了这个坑:用户点完赞立刻切页面,结果 toast 永远不弹。如果你需要导航或 toast 这类副作用,要用 mutate(value, { onSuccess }) 的调用级回调。

useInfiniteQuery 与 FlashList 实现无限滚动

"How do you handle pagination with React Query?"是 PAA 里最常见的问题之一。v5 的 useInfiniteQuery 重新设计了类型,initialPageParam 现在是必填项,getNextPageParam 接收完整的 pages 数组。配合 FlashList v2 的视图回收,可以流畅展示数千行数据。

// screens/FeedScreen.tsx
import { FlashList } from '@shopify/flash-list';
import { useInfiniteQuery } from '@tanstack/react-query';

type Page = { items: Post[]; nextCursor: string | null };

export default function FeedScreen() {
  const query = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(`/api/feed?cursor=${pageParam ?? ''}`);
      return (await res.json()) as Page;
    },
    initialPageParam: null as string | null,
    getNextPageParam: (last) => last.nextCursor,
  });

  const items = query.data?.pages.flatMap((p) => p.items) ?? [];

  return (
    <FlashList
      data={items}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <PostRow post={item} />}
      onEndReached={() => {
        if (query.hasNextPage && !query.isFetchingNextPage) {
          query.fetchNextPage();
        }
      }}
      onEndReachedThreshold={0.5}
      onRefresh={() => query.refetch()}
      refreshing={query.isRefetching}
    />
  );
}

FlashList 的视图回收会让 renderItem 在快速滚动时被频繁调用,所以 PostRow 一定要用 React.memo 包裹,并把任何函数 prop 用 useCallback 稳定下来。这是和 Web 的 React 表现差异最大的地方。如果对列表性能本身有疑问,可以看 FlatList vs FlashList v2 vs LegendList 性能对比那篇。

配合 React 19 Suspense 与 ErrorBoundary

React Native 0.78 之后默认启用 Bridgeless 模式与 React 19,useSuspenseQuery 可以彻底告别组件内的 isLoading/isError 判断。加载状态由外层 Suspense 接管,错误由 ErrorBoundary 接管。

// screens/ProfileScreen.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useSuspenseQuery } from '@tanstack/react-query';

function ProfileBody({ userId }: { userId: string }) {
  // 注意:useSuspenseQuery 永远返回 data,类型是非可空的
  const { data } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });
  return <Text>{data.name}</Text>;
}

export default function ProfileScreen({ userId }: { userId: string }) {
  return (
    <ErrorBoundary fallback={<Text>加载失败,请下拉重试</Text>}>
      <Suspense fallback={<ActivityIndicator />}>
        <ProfileBody userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

这种结构的好处是组件内部代码可以默认"数据存在",UI 状态机大幅简化。但 Suspense 也有代价:每次 queryKey 变化都会重新挂起组件树,如果父组件存在频繁变化的 key,体验上会闪一下。这种情况要么使用 useQuery 退回旧的状态模式,要么用 startTransition 标记切换为低优先级。新架构相关的渲染模型细节可参考 React Native 新架构完全指南

TanStack Query 与 Redux/Zustand 怎么分工

这是 PAA 中"Should I use Redux or TanStack Query?"的真实答案:不是二选一,是各管各的。

维度TanStack Query v5Zustand v5Redux Toolkit
定位服务端状态(缓存、同步)客户端状态(UI、表单)客户端状态(大型应用)
缓存与重试内置需 RTK Query 子模块
离线持久化persistQueryClient + MMKVpersist 中间件redux-persist
样板代码量极少极少中(slice、thunk)
学习曲线中等(生命周期概念多)较高
React Native 适配需手动接 AppState/NetInfo开箱即用开箱即用

我个人推荐的栈是 TanStack Query 管所有服务端数据,加上 Zustand 管 UI/会话状态(深色模式、当前编辑中的表单草稿、未发送的离线消息队列)。这样两者完全不冲突,Redux 几乎可以从 2026 年的 RN 项目里淘汰掉,除非你已经有大量遗留代码。

常见问题

TanStack Query 在 React Native 离线时会怎样?

配置了 onlineManager 后,断网时新的请求会进入 paused 状态等待网络恢复;已经缓存的数据继续可读;mutation 也会被排队,联网后自动按顺序重试。如果没接 NetInfo,请求会立刻失败并按 retry 配置进行指数退避,体验比较差。

为什么我的 App 切到后台再回来不会自动刷新数据?

因为 React Native 没有浏览器的 focus 事件,TanStack Query 的 refetchOnWindowFocus 在 RN 里默认无效。必须手动用 focusManager.setFocused 监听 AppState 的 active 状态,本文"用 AppState 替换浏览器 focus 事件"一节有完整代码。

React Query 和 SWR 在 React Native 上选哪个?

2026 年建议选 TanStack Query v5。SWR 在简单场景下更轻量,但缺少 mutation 队列、离线暂停、Suspense 集成与持久化生态,RN 场景几乎一定要这些功能。如果项目只是几个只读接口、不打算做离线,SWR 也是合理选择。

查询缓存能在 App 冷启动后保留吗?

能,但要主动接 persistQueryClient 并使用同步存储(推荐 MMKV)。AsyncStorage 也能用但性能差很多;不接持久化的话,每次启动都是空缓存。

从 React Query v4 升级到 v5 主要要改什么?

核心变更:所有 queryKey 必须是数组;useQuery 不再接受位置参数,必须用对象形式;useMutation 移除顶层 onSuccess/onError 改为 mutation 级;useInfiniteQuery 必须显式提供 initialPageParam。详见 官方 v5 迁移指南

Anita Iyer
关于作者 Anita Iyer

Cross-platform mobile developer who came to RN from web. Bridges the two worlds and explains the seams.