- 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 的关键变化是默认开启 structuralSharing、queryKey 必须是数组、移除了回调式 onSuccess/onError(改用 useEffect 或 useMutation 的回调),所以从旧文档抄代码时务必核对版本。
我在自己的项目里把 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 文档会告诉你 active、background、inactive 三种状态的语义。
在应用入口(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 的 useMutation 用 onMutate + 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 v5 | Zustand v5 | Redux Toolkit |
| 定位 | 服务端状态(缓存、同步) | 客户端状态(UI、表单) | 客户端状态(大型应用) |
| 缓存与重试 | 内置 | 无 | 需 RTK Query 子模块 |
| 离线持久化 | persistQueryClient + MMKV | persist 中间件 | 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 迁移指南。