TanStack Query — 服务端状态管理
TanStack Query(原 React Query)专门管理服务端状态:缓存、同步、后台更新、乐观更新,让数据获取变得简单。
核心概念
客户端状态(Client State):UI 状态、表单、主题
→ 用 useState / Zustand / Pinia
服务端状态(Server State):来自 API 的数据
→ 用 TanStack Query
服务端状态的挑战:
- 缓存(何时失效?)
- 后台同步(数据是否过期?)
- 重复请求去重
- 分页 / 无限滚动
- 乐观更新
- 错误重试安装与配置
tsx
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 分钟内数据不过期
retry: 2, // 失败重试 2 次
refetchOnWindowFocus: true, // 窗口聚焦时重新获取
}
}
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}useQuery 基础
tsx
import { useQuery } from '@tanstack/react-query'
// 查询键(Query Key):唯一标识一个查询,用于缓存
function UserProfile({ userId }: { userId: number }) {
const {
data: user,
isLoading,
isError,
error,
isFetching, // 后台重新获取时为 true
refetch
} = useQuery({
queryKey: ['user', userId], // userId 变化时自动重新获取
queryFn: () => fetchUser(userId),
enabled: userId > 0, // 条件查询
staleTime: 10 * 60 * 1000, // 10 分钟
select: (data) => ({ // 转换数据
...data,
fullName: `${data.firstName} ${data.lastName}`
})
})
if (isLoading) return <Skeleton />
if (isError) return <Error message={error.message} />
return (
<div>
<h1>{user.fullName}</h1>
{isFetching && <span>更新中...</span>}
<button onClick={() => refetch()}>刷新</button>
</div>
)
}useMutation
tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePostForm() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (newPost: CreatePostInput) => api.createPost(newPost),
// 成功后使相关查询失效(触发重新获取)
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
toast.success('发布成功!')
},
onError: (error) => {
toast.error(`发布失败: ${error.message}`)
}
})
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
mutation.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string
})
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<textarea name="content" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '发布中...' : '发布'}
</button>
</form>
)
}乐观更新
tsx
function LikeButton({ postId, liked }: { postId: number; liked: boolean }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: () => api.toggleLike(postId),
// 乐观更新:立即更新 UI,不等待服务器响应
onMutate: async () => {
// 取消正在进行的查询(避免覆盖乐观更新)
await queryClient.cancelQueries({ queryKey: ['post', postId] })
// 保存当前数据(用于回滚)
const previousPost = queryClient.getQueryData(['post', postId])
// 乐观更新缓存
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1
}))
return { previousPost } // 传给 onError
},
// 失败时回滚
onError: (err, variables, context) => {
queryClient.setQueryData(['post', postId], context?.previousPost)
toast.error('操作失败,已恢复')
},
// 无论成功失败,最终都重新获取确保数据一致
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['post', postId] })
}
})
return (
<button onClick={() => mutation.mutate()}>
{liked ? '❤️' : '🤍'} 点赞
</button>
)
}分页与无限滚动
tsx
// 分页
function PostList() {
const [page, setPage] = useState(1)
const { data, isPlaceholderData } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: keepPreviousData, // 翻页时保留旧数据,避免闪烁
})
return (
<div>
{data?.posts.map(post => <PostCard key={post.id} post={post} />)}
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>上一页</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>下一页</button>
</div>
)
}
// 无限滚动
import { useInfiniteQuery } from '@tanstack/react-query'
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
})
const allPosts = data?.pages.flatMap(page => page.posts) ?? []
return (
<div>
{allPosts.map(post => <PostCard key={post.id} post={post} />)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? '加载中...' : hasNextPage ? '加载更多' : '没有更多了'}
</button>
</div>
)
}封装 API 层
ts
// hooks/useUsers.ts
export function useUser(id: number) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => userApi.getById(id),
enabled: id > 0
})
}
export function useUsers(filters?: UserFilters) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => userApi.getAll(filters)
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: userApi.create,
onSuccess: () => queryClient.invalidateQueries({ queryKey: userKeys.lists() })
})
}
// 查询键工厂(避免字符串拼写错误)
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters?: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
}总结
- TanStack Query 专注服务端状态,不要用它管理 UI 状态
queryKey是缓存的核心,包含所有影响数据的变量invalidateQueries使缓存失效,触发重新获取- 乐观更新提升用户体验,配合
onError回滚保证数据一致性 - 封装查询键工厂,避免字符串拼写错误