Skip to content

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 回滚保证数据一致性
  • 封装查询键工厂,避免字符串拼写错误

系统学习 Web 前端生态,深入底层架构