React 数据获取:从 fetch 到 React Query
useEffect+fetch 看着简单,坑极多(竞态/重复请求/无缓存/无重试)。本文讲清 React 数据获取的演进:useEffect(反模式)→ React Query/SWR(服务端状态库)→ Server Components(RSC 服务端获取),各自该用在哪,以及 RSC 和 RQ 不是替代是互补。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
React 数据获取:从 fetch 到 React Query
很多人写 React 的第一段数据获取代码长这样:useEffect 里 fetch,然后 setState。它能跑,甚至看起来很简洁。但只要你把它推到生产环境,就会在某个深夜被它咬一口——竞态请求、重复请求、无缓存、无重试、loading 状态闪烁、内存泄漏警告……这些问题不是你写得不够仔细,而是 useEffect + fetch 这个组合本身就是个反模式。
这篇文章讲清 React 数据获取的演进路线:从手写的 useEffect,到 React Query/SWR 这类服务端状态库,再到 Server Components 的服务端获取。最后我会给出一个我实战中最常用的组合模式。
一、useEffect + fetch:一个看着简单的反模式
先看教科书式的写法:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error('Failed')
return res.json()
})
.then((data) => {
if (!cancelled) {
setUser(data)
setError(null)
}
})
.catch((err) => {
if (!cancelled) setError(err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [userId])
if (loading) return <Spinner />
if (error) return <ErrorView />
return <UserCard user={user} />
}
为了"写对"这一段,我已经写了 30 行。但它仍然有不少坑:
- 竞态条件(race condition):
userId连续从1变到2,如果请求2比请求1先返回呢?那个cancelled标志位就是为了防这个——但很多人根本不写,结果就是 UI 显示了"旧请求"的数据。 - 重复请求:同组件在多处挂载,或者用户在列表页来回切换,每次都重新发一遍。没有任何缓存。
- 无重试:网络抖动一次就
error了,用户只能手动刷新。 - 无陈旧刷新:数据已经躺在屏幕上一小时了,后台早就变了,UI 毫无感知。
- 窗口聚焦不更新:用户切到别的 Tab 再切回来,数据是"旧的"。
- 手动管理 loading/error:三种状态(
loading/error/success)在每个组件里都要手写一遍,样板代码爆炸。
问题的根源在于:useEffect 是用来同步副作用到外部世界的逃生舱,不是用来获取数据的。React 官方文档现在也明确不推荐这种写法。你需要的是一个专门处理"服务端状态"的抽象层。
二、React Query / SWR:服务端状态库
React Query(现在叫 TanStack Query)和 SWR 解决的正是上述问题。它们的核心定位是:你的客户端状态(state)和服务端状态(server state)是两种东西,不该用同一套 useState 管理。
服务端状态有几个特点:它不归你拥有(归远端数据库)、它随时可能变(陈旧)、它需要异步获取、它需要缓存和去重。用 useState 管这些,等于在重新发明一个残缺的缓存层。
换成 React Query,上面 30 行变成这样:
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
staleTime: 60_000,
})
if (isLoading) return <Spinner />
if (error) return <ErrorView />
return <UserCard user={user} />
}
一行 useQuery,你免费拿到了:
- 自动缓存 + 请求去重(同一个 query key 并发只发一次)
- 自动重试(失败默认重试 3 次)
- 陈旧刷新(
staleTime过后视为陈旧,窗口聚焦/重新挂载时自动重取) - loading / error / success 状态全套
- 内存泄漏防护(组件卸载自动清理)
有人算过,这一行 useQuery 等价于你手写大约 50 行等价逻辑。这叫用对抽象。
三、RQ 的核心概念:query key、staleTime、invalidate
要用好 RQ,先搞懂三个概念。
Query Key(查询键) 是缓存的唯一标识。它是个数组,可以嵌套:
queryKey: ['users'] // 所有用户
queryKey: ['users', { page: 2 }] // 第 2 页用户
queryKey: ['user', userId] // 某个用户
RQ 会对 key 做序列化比较,['user', 1] 和 ['user', '1'] 是不同的缓存。把所有影响请求的参数都塞进 key,这是它"依赖追踪"的方式——不用你手写依赖数组。
staleTime(陈旧时间) 控制数据多久算"陈旧"。默认是 0,意思是一拿到就陈旧——所以默认每次窗口聚焦、重新挂载都会重取(虽然先返回缓存,体验上是即时的)。如果你数据更新不频繁,设个 60_000(1 分钟)能大幅减少无谓请求。
refetchOnWindowFocus(窗口聚焦重取) 默认开启。听起来像 bug,其实是产品经理梦想中的功能——用户切回来,数据自动是最新的。不喜欢就关掉。
invalidateQueries(失效查询) 是写操作后刷新数据的关键。改了用户名,你得告诉 RQ"这个缓存废了,重新取":
queryClient.invalidateQueries({ queryKey: ['user', userId] })
它会让匹配的 query 标记为陈旧并后台重取。注意它是"模式匹配"的——invalidateQueries({ queryKey: ['users'] }) 会失效所有以 users 开头的查询,非常适合"新建了一个用户,列表和详情都刷新"这种场景。
四、mutation:写操作与乐观更新
读用 useQuery,写用 useMutation。一个 mutation 长这样:
const mutation = useMutation({
mutationFn: (newName: string) =>
fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] })
},
})
调用 mutation.mutate('Alice') 触发。成功后失效缓存,RQ 自动重取最新数据。这是最稳妥的模式。
但有时你想让 UI 更"快"——点了保存立刻看到新名字,不等服务器确认。这就是乐观更新(optimistic update):先改 UI,再确认,失败就回滚:
const mutation = useMutation({
mutationFn: (newName: string) =>
fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
}),
onMutate: async (newName) => {
// 取消正在进行的查询,避免覆盖我们的乐观值
await queryClient.cancelQueries({ queryKey: ['user', userId] })
// 快照旧值,用于回滚
const prev = queryClient.getQueryData<User>(['user', userId])
// 乐观写入新值
queryClient.setQueryData<User>(['user', userId], (old) =>
old ? { ...old, name: newName } : old,
)
return { prev }
},
onError: (_err, _newName, context) => {
// 失败,回滚到旧值
queryClient.setQueryData(['user', userId], context?.prev)
},
onSettled: () => {
// 无论成败,后台重取拿服务端真实状态
queryClient.invalidateQueries({ queryKey: ['user', userId] })
},
})
乐观更新的核心就是 onMutate(改前快照 + 写乐观值)→ onError(回滚)→ onSettled(重取对账)。这套路写熟了,你的"保存"按钮会快得像本地操作。
五、Server Components:服务端获取
到了 React Server Components(RSC),数据获取又变了。它直接在服务端 await:
// app/users/[id]/page.tsx
export default async function UserProfile({
params,
}: {
params: { id: string }
}) {
const user = await fetch(`https://api.example.com/users/${params.id}`, {
next: { revalidate: 60, tags: ['user'] },
}).then((r) => r.json())
return <UserCard user={user} />
}
这段代码整个在服务端跑,发到浏览器的只有渲染好的 HTML,fetch 逻辑和它依赖的库零客户端 JS 成本。顺带,因为是服务端渲染,SEO 天然友好,首屏也不用闪 loading 了。
它特别适合"首屏数据":博客正文、商品详情、列表第一页——这些不需要交互触发、又是 SEO 重点的内容。
六、RSC vs RQ:不是替代,是互补
很多人(包括以前的我)有个误区:有了 RSC 是不是就不用 React Query 了?不是。它俩解决的是不同问题。
- RSC 负责首屏、服务端数据:在服务端取、服务端渲染,零 JS 成本。但它只在服务端渲染那一刻取一次,用户交互之后呢?它不会自己刷新。
- RQ 负责客户端动态数据:用户 mutate 之后刷新、切回 Tab 自动更新、跨页面缓存共享——这些 RSC 做不了。
举个具体场景:一个商品详情页,首屏用 RSC 取商品信息(快、SEO 好);用户点了"加入收藏"后,收藏按钮的状态、购物车角标需要更新——这部分交给客户端的 RQ mutation + invalidate。一个负责"静态的首屏",一个负责"动态的交互后",各司其职。
七、实战模式:首屏 RSC + 交互 RQ + mutation
这是我在 Next.js 项目里最常用的组合拳,直接抄:
// app/posts/page.tsx —— Server Component,首屏服务端取
import { getPosts } from '@/lib/api'
import { PostsList } from '@/components/PostsList'
export default async function Page() {
const initialPosts = await getPosts() // 服务端取,零客户端 JS
return <PostsList initialPosts={initialPosts} />
}
// components/PostsList.tsx —— Client Component,交互后用 RQ
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function PostsList({ initialPosts }: { initialPosts: Post[] }) {
const queryClient = useQueryClient()
// 用 initialData 把首屏数据"喂"给 RQ,避免二次请求
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: initialPosts,
staleTime: 60_000,
})
// 写用 mutation + invalidate
const mutation = useMutation({
mutationFn: (title: string) => createPost(title),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
return (
<>
<button onClick={() => mutation.mutate('新文章')}>发文</button>
{posts?.map((p) => <article key={p.id}>{p.title}</article>)}
</>
)
}
这个模式的好处:首屏快(服务端取,无 loading 闪烁)、交互顺(RQ 接管后续,自动缓存和刷新)、写操作清晰(mutation + invalidate 一套打完)。initialData 是连接 RSC 和 RQ 的桥梁——RSC 把首屏数据递给 RQ 当缓存起点,RQ 接着干活。
八、缓存策略:按数据新鲜度调
RQ 的默认行为很"激进":staleTime = 0,意味着数据一到手就陈旧,窗口聚焦就重取。这对很多应用来说请求量太大了。我通常这样调:
- 高频变化(消息列表、通知):
staleTime: 0,保持默认,勤更新。 - 中频(用户资料、订单列表):
staleTime: 30_000~60_000,30 秒到 1 分钟内信任缓存。 - 低频(配置、字典数据、静态内容):
staleTime: Infinity(或几小时),基本不重取。 gcTime(旧缓存保留时间,原cacheTime):默认 5 分钟。一个 query 的所有 observer 都卸载后,缓存还会留 5 分钟,5 分钟内重新挂载就直接命中。低频数据想长留就调大。
调缓存的核心原则:信任数据的时长,应该和数据本身的真实变化频率匹配。不要让一个几乎不变的字典表每 30 秒重取一次,也不要让一个实时通知 5 分钟都不更新。
小结
useEffect + fetch是反模式:竞态、重复请求、无缓存、无重试、样板代码爆炸,生产环境里它迟早咬你。- React Query / SWR 把"服务端状态"和"客户端状态"分开,一行
useQuery替代 50 行手写逻辑,缓存/去重/重试/陈旧刷新全自动。 - RQ 三件套:
queryKey(缓存键)、staleTime(新鲜期)、invalidateQueries(写后刷新)。写操作用useMutation,要快就上乐观更新。 - RSC 负责首屏和服务端数据(零 JS、SEO 友好),RQ 负责交互后的动态数据——不是替代,是互补。
- 实战黄金组合:首屏 RSC 取数据 → 用
initialData喂给 RQ → 写操作 mutation + invalidate。缓存按数据真实变化频率调staleTime。
金句:数据获取不该是客户端的独角戏,也不该是服务端的独角戏——首屏交给服务端,交互留给客户端,用对工具而不是用对框架。
Related