首页 / 文章 / 构建 TanStack Query 数据层:从 queryOptions 到回滚机制

构建 TanStack Query 数据层:从 queryOptions 到回滚机制

逐步构建 TanStack Query 数据层:共享的 queryOptions、键生成器、选择器、分页功能、预加载机制、集中式无效化处理以及安全的乐观更新方式。

2730 词

TanStack Query(原名React Query)的代码库中往往会出现重复的键、被遗忘的无效化处理以及随处可见的加载指示器,这并非因为该库本身的问题,而是由于缺乏合理的结构设计。本指南将从一个简单的查询功能开始,逐步构建出一个包含共享选项、键生成器、自动无效化处理以及乐观删除功能的微型数据层,并指出每种设计模式背后隐藏的隐患。如果您仍在考虑服务器状态应存储在何处,我们关于React Query与Redux在大型应用中处理服务器状态的区别的文章可以首先解答您的疑问。

最实用的最小查询

一个查询需要一个键和一个函数。该组件负责获取联系人信息,并处理待处理状态和错误状态。

import { useQuery } from '@tanstack/react-query';
import { getContacts } from './api/client';

function ContactsTable() {
  const { data, isPending, isError, refetch } = useQuery({
    queryKey: ['contacts'],
    queryFn: getContacts,
  });
  if (isPending) return <LoadingSpinner />;
  if (isError) return <ErrorAlert onRetry={refetch} />;

  return <Table data={data} />;
}

queryKey并非标签,而是缓存条目的标识:所有请求['contacts']的组件都会共享相同的数据、相同的请求去重机制以及相同的后台数据刷新功能。下面介绍的几乎所有方法其实都是为了更好地管理这些键。

共享查询定义

将重复的查询封装在自定义钩子中

当两个组件需要相同的数据时,可通过自定义钩子保存一份查询定义,让其他组件仅负责界面展示。

// queries/contacts.ts
export function useContacts() {
  return useQuery({
    queryKey: ['contacts'],
    queryFn: getContacts,
  });
}

// Component
function ContactsTable() {
  const { data, isPending, isError } = useContacts();
  // Clean, focused component logic
}

优先使用queryOptions对象而非钩子

钩子只能在组件内部被调用,而使用queryOptions创建的普通选项对象则可以被钩子、prefetchQuerygetQueryData以及路由加载器所使用。

import { queryOptions } from '@tanstack/react-query';

export const contactsQueryOptions = queryOptions({
  queryKey: ['contacts'],
  queryFn: getContacts,
});

它有两个优点。首先,类型推断:queryOptions会为键标注查询的数据类型,因此无需手动使用泛型,queryClient.getQueryData(contactsQueryOptions.queryKey)就能直接返回带类型的信息。其次,可组合性:你可以展开该对象,并根据每次调用的场景覆盖或添加字段,就像第二个组件使用select那样。

// Use directly
function ContactsList() {
  const { data } = useQuery(contactsQueryOptions);
  return <List items={data} />;
}

// Extend with custom selectors
function ContactsCount() {
  const { data } = useQuery({
    ...contactsQueryOptions,
    select: (contacts) => contacts.length,
  });
  return <Badge count={data} />;
}

高效读取数据

减少重绘的选择器

select会在数据到达组件之前对其进行转换,同时起到渲染优化的作用。它还可以存在于共享选项中:

const contactsQueryOptions = queryOptions({
  queryKey: ['contacts'],
  queryFn: getContacts,
  select: (data) => data.length,
});

该组件会根据选定的结果重新渲染。如果仅选择了数量,而服务器更改了某个联系人的姓名,数量不会改变,组件也不会重新渲染。在许多组件需要读取同一份大型列表的界面中,这可以避免大量不必要的渲染。不过有一个需要注意的地方:内联的select箭头在每次渲染时都会被当作新函数处理,因此会重复执行相关操作;对于计算成本较高的转换操作,应将其定义在组件外部或进行缓存处理。

参数化查询:所有输入都应包含在键中

详情页需要一个基于ID的查询方式,而返回选项的工厂函数有助于保持代码的整洁。

export const contactQueryOptions = (contactId: string) =>
  queryOptions({
    queryKey: ['contacts', contactId],
    queryFn: () => getContact(contactId),
  });

// Usage
function ContactPage() {
  const { id } = useParams();
  const { data } = useQuery(contactQueryOptions(id));
  return <ContactDetails contact={data} />;
}

这条规则可避免最常见的生产环境错误之一:查询函数使用的每个变量都必须出现在键中。如果省略contactId,缓存会将所有联系人视为一个条目,从而导致用户可能短暂看到其他人的信息。使用路由器时还需注意id可能为undefinedenabled选项可让你在该值存在之前暂存查询。

分页即参数化查询加状态管理

分页无需特殊处理:页码会被放入键中,而在状态中更改页码则会生成新的查询。

export const paginatedContactsOptions = (page: number, pageSize: number) =>
  queryOptions({
    queryKey: ['contacts', 'paginated', page, pageSize],
    queryFn: () => getContacts({ page, pageSize }),
  });

function ContactsTable() {
  const [page, setPage] = useState(1);
  const { data } = useQuery(paginatedContactsOptions(page, 10));
  return (
    <>
      <Table data={data.items} />
      <Pagination
        currentPage={page}
        onNext={() => setPage(p => p + 1)}
      />
    </>
  );
}

无需进行重新获取操作;新的键值仅意味着会发起新的查询。实际使用中有两点需要改进。在首次渲染时,data的值为undefined,因此data.items需要添加保护逻辑。此外,在每次页面切换时,新的键值初始为空,会导致表格内容瞬间消失;设置placeholderData: keepPreviousData可在加载新页面的同时保持上一页的可见性。

预取用户接下来会访问的页面

将此方法与预取功能结合,通常在用户点击之前就能准备好下一页内容。

function ContactsTable() {
  const [page, setPage] = useState(1);
  const queryClient = useQueryClient();
  const { data } = useQuery(paginatedContactsOptions(page, 10));

useEffect(() => {
    // Silently load the next page in the background
    queryClient.prefetchQuery(
      paginatedContactsOptions(page + 1, 10)
    );
  }, [page, queryClient]);
  return <Table data={data} />;
}

prefetchQuery会在不绑定组件的情况下填充缓存。当用户切换到下一页时,该查询会获取最新数据并立即进行渲染。它在鼠标悬停或路由加载之前也能发挥作用,但需注意控制使用频率:每次预取操作都相当于一次真实的请求。

带光标的无限列表

对于“加载更多”或无限滚动功能,useInfiniteQuery会存储页面列表并自动跟踪光标位置。你需要在getNextPageParam中指定如何找到下一个光标位置,而该库会负责后续的处理。

export const infiniteContactsOptions = queryOptions({
  queryKey: ['contacts', 'infinite'],
  queryFn: ({ pageParam }) => getContacts({ cursor: pageParam }),
  initialPageParam: undefined,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
});

function InfiniteContactsList() {
  const {
    data,
    fetchNextPage,
    isFetchingNextPage
  } = useInfiniteQuery(infiniteContactsOptions);
  return (
    <>
      {data.pages.map(page =>
        page.items.map(contact => (
          <ContactCard key={contact.id} {...contact} />
        ))
      )}
      <button onClick={() => fetchNextPage()}>
        {isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    </>
  );
}

请注意,在TanStack Query v5中,用于处理此类场景的专用辅助函数是infiniteQueryOptions,它能为无限滚动相关的字段设置正确的类型;如果queryOptions拒绝这些类型,请查阅最新文档。此外,当getNextPageParam返回undefined时,可使用hasNextPage来隐藏对应按钮。

通过工厂函数保持键值的一致性

手动编写的键值容易出错:某个文件使用['contacts', 'list'],另一个文件则使用['contact', 'lists'],这样会导致无效更新被忽略。而键值工厂函数只需定义一次层级结构即可。

export const contactKeys = {
  all: ['contacts'] as const,
  lists: () => [...contactKeys.all, 'list'] as const,
  list: (filters: ContactFilters) =>
    [...contactKeys.lists(), filters] as const,
  details: () => [...contactKeys.all, 'detail'] as const,
  detail: (id: string) =>
    [...contactKeys.details(), id] as const,
};

// Usage in queries
export const contactQueryOptions = (id: string) =>
  queryOptions({
    queryKey: contactKeys.detail(id),
    queryFn: () => getContact(id),
  });
// Surgical cache invalidation
queryClient.invalidateQueries({
  queryKey: contactKeys.all
}); // Invalidates everything
queryClient.invalidateQueries({
  queryKey: contactKeys.lists()
}); // Only list queries

由于键是通过前缀匹配的,这种层级结构使得你可以选择广泛或精确地使数据失效:contactKeys.all会刷新与联系人相关的所有内容,而contactKeys.lists()仅影响列表查询,不会改动已缓存的详细信息。

通过变异操作修改数据

基本的变异操作钩子

写入操作需通过useMutation实现。将其封装在钩子中可以将诸如提示信息之类的副作用与请求操作分开处理。

export function useDeleteContact() {
  return useMutation({
    mutationFn: (contactId: string) => deleteContact(contactId),
    onSuccess: () => {
      toast.success('Contact deleted successfully');
    },
    onError: () => {
      toast.error('Failed to delete contact');
    },
  });
}

// Usage in components
function ContactCard({ contact }) {
  const { mutate, isPending } = useDeleteContact();
  return (
    <Card>
      <h3>{contact.name}</h3>
      <button
        onClick={() => mutate(contact.id)}
        disabled={isPending}
      >
        {isPending ? 'Deleting...' : 'Delete'}
      </button>
    </Card>
  );
}

onSuccessonErroronSettled会在相应的阶段被调用;isPending则便于在请求处理过程中禁用按钮。

指定变异操作会使哪些数据失效

写入操作完成后,必须重新获取受影响的查询结果。无需在每个钩子函数中都调用invalidateQueries,而是可以让变更操作在meta中指定目标,再由一个全局处理函数统一处理这些目标。

// In your mutation
export function useDeleteContact() {
  return useMutation({
    mutationFn: (contactId: string) => deleteContact(contactId),
    meta: {
      invalidates: [contactKeys.all],
    },
  });
}

// Global setup (one time, in main.tsx)
const queryClient = new QueryClient({
  defaultOptions: {
    mutations: {
      onSettled: async (data, error, variables, context) => {
        const meta = context?.meta;
        if (meta?.invalidates) {
          await Promise.all(
            meta.invalidates.map((queryKey) =>
              queryClient.invalidateQueries({ queryKey })
            )
          );
        }
      },
    },
  },
});

这个思路本身不错,但需根据你所使用的版本核对接线方式。在所示的回调签名中,第四个参数是onMutate返回的值而非变更操作本身,因此context?.meta无法找到已声明的键。此外,那些定义了自身onSettled方法的变更操作会替代此默认回调而非与其并行执行。更合适的处理方式是将MutationCache传递给QueryClient:其回调会接收到包含mutation.meta的变更对象,并且始终会在各变更操作对应的回调之外执行。在TypeScript中,若要为meta.invalidates添加类型定义,则需要注册自定义的Meta类型。

全局错误处理

诸如会话过期之类的跨领域故障也应集中处理在同一个地方。

const queryClient = new QueryClient({
  defaultOptions: {
    mutations: {
      onError: (error) => {
        // Handle authentication globally
        if (error.status === 401) {
          logout();
          navigate('/login');
        }

        // Handle network errors
        if (error.message === 'Network Error') {
          toast.error('Check your connection');
        }
      },
    },
  },
});

所有变异操作在遇到401错误或网络故障时都会给出相同的响应。同样的注意事项也适用:上面的useDeleteContact定义了它自己的onError处理函数,会替代此默认行为,因此MutationCacheonError仍是可靠的选择。另外请注意,error.status以及“网络错误”这类消息取决于所使用的HTTP客户端;Axios会输出后者,而fetch则会抛出TypeError

乐观更新

乐观UI会在服务器确认之前就显示写入操作的结果。这种机制分为两个层级。

UI层级:在项目删除请求待处理时将其隐藏

useMutationState 可以在数据树的任意位置查看正在处理的突变操作。通过键和状态进行过滤后,即可得到当前正在被删除的项的 ID,列表只需将这些项隐藏起来即可。

function useContactsBeingDeleted() {
  return useMutationState({
    filters: {
      mutationKey: ['deleteContact'],
      status: 'pending'
    },
    select: (mutation) => mutation.state.variables,
  });
}

function ContactsList() {
  const { data: contacts } = useContacts();
  const deletingIds = useContactsBeingDeleted();

  // Filter out contacts being deleted
  const visibleContacts = contacts.filter(
    c => !deletingIds.includes(c.id)
  );

  return <List items={visibleContacts} />;
}

缓存中的内容不会发生变化,因此如果请求失败,相关项会自动重新出现。这种匹配依赖于突变操作具有 mutationKey: ['deleteContact'],而之前的钩子函数并未设置该值;必须添加该值,否则过滤器将找不到任何匹配项。

缓存层:修改缓存并在失败时回滚

更彻底的方法是直接修改缓存中的列表,这样所有读取该列表的组件都会同时更新。

export function useDeleteContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: deleteContact,
    onMutate: async (contactId) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({
        queryKey: contactKeys.lists()
      });

      // Snapshot current value
      const previousContacts = queryClient.getQueryData(
        contactKeys.lists()
      );
      // Optimistically update
      queryClient.setQueryData(
        contactKeys.lists(),
        (old) => old.filter(c => c.id !== contactId)
      );
      // Return rollback data
      return { previousContacts };
    },
    onError: (err, variables, context) => {
      // Rollback on error
      if (context?.previousContacts) {
        queryClient.setQueryData(
          contactKeys.lists(),
          context.previousContacts
        );
      }
    },
    onSettled: () => {
      // Always refetch for consistency
      queryClient.invalidateQueries({
        queryKey: contactKeys.lists()
      });
    },
  });
}

顺序很重要。cancelQueries会阻止正在进行的重新获取操作覆盖乐观更新的结果。快照是从onMutate中返回的,这样onError就能恢复它。onSettled无论请求是成功还是失败都会重新获取数据,从而使缓存与服务器保持一致。同时要注意键的匹配:getQueryDatasetQueryData必须完全一致,因此如果您的列表是存储在contactKeys.list(filters)下的,那么操作contactKeys.lists()不会影响到任何数据;而setQueriesData则会更新前缀下的所有条目。还需对old值加以保护,因为列表可能尚未被缓存。

加载状态的悬停处理

useSuspenseQuery能确保data已被定义,并将等待状态交给React的组件处理。

// Change from useQuery to useSuspenseQuery
function ContactsList() {
  const { data } = useSuspenseQuery(contactsQueryOptions);
  // No isPending check needed!
  return <Table data={data} />;
}

function ContactDetails({ id }) {
  const { data } = useSuspenseQuery(contactQueryOptions(id));
  return <Details contact={data} />;
}
// Centralized loading UI
function App() {
  return (
    <Suspense fallback={<AppSkeleton />}>
      <ContactsList />
      <ContactDetails id="123" />
    </Suspense>
  );
}

通过一个边界将分散的加载指示器整合为单一的结构。其代价在于精度:该边界会等待其中最慢的子组件,因此应将其设置在合并后的加载状态确实有意义的部位,并预取数据以避免连续发起大量请求。

组合后的模式

以下是整合了各组件的联系人功能:键值生成器、参数化选项、用于声明无效并采用乐观更新方式的删除操作,以及具备暂停与预取功能的列表。

// queries/contacts.ts
export const contactKeys = {
  all: ['contacts'] as const,
  lists: () => [...contactKeys.all, 'list'] as const,
  list: (filters: Filters) => [...contactKeys.lists(), filters] as const,
  detail: (id: string) => [...contactKeys.all, id] as const,
};

export const contactsQueryOptions = (filters: Filters) =>
  queryOptions({
    queryKey: contactKeys.list(filters),
    queryFn: () => getContacts(filters),
  });
export function useDeleteContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: deleteContact,
    mutationKey: ['deleteContact'],
    meta: { invalidates: [contactKeys.all] },
    onMutate: async (contactId) => {
      await queryClient.cancelQueries({
        queryKey: contactKeys.lists()
      });

      const previous = queryClient.getQueryData(
        contactKeys.lists()
      );

      queryClient.setQueryData(
        contactKeys.lists(),
        (old) => old?.filter(c => c.id !== contactId)
      );

      return { previous };
    },
    onError: (err, variables, context) => {
      if (context?.previous) {
        queryClient.setQueryData(
          contactKeys.lists(),
          context.previous
        );
      }
    },
  });
}
// components/ContactsList.tsx
function ContactsList() {
  const [page, setPage] = useState(1);
  const queryClient = useQueryClient();

  const { data } = useSuspenseQuery(
    contactsQueryOptions({ page, pageSize: 20 })
  );

  const { mutate: deleteContact } = useDeleteContact();
  // Prefetch next page
  useEffect(() => {
    queryClient.prefetchQuery(
      contactsQueryOptions({ page: page + 1, pageSize: 20 })
    );
  }, [page, queryClient]);
  return (
    <Table
      data={data.items}
      onDelete={deleteContact}
      pagination={{ page, onChange: setPage }}
    />
  );
}

最终实现的是端到端的类型安全处理,速度快,且将缓存规则集中在一个模块中。需要注意的一点是:使用 useSuspenseQuery 时,每次更改 page 都会重新触发暂停操作,从而导致每次页面切换时都显示备用内容;而将 setPage 包装在 startTransition 中,则能在加载新页面的同时保持当前页面显示在屏幕上。

核心要点

  • 将每个查询输入映射到其对应的键;这一规则即可避免大多数缓存错误。
  • 将查询定义为queryOptions对象,这样钩子、预取和缓存读取就能使用同一类型化的数据源。
  • 尽早采用键生成器;前缀匹配能让失效操作更加精准。
  • 将失效处理和错误处理集中管理,最好在MutationCache回调中实现,避免单个组件的回调悄悄覆盖这些处理逻辑。
  • 对于简单的隐藏操作,采用UI层的乐观处理方式;而当多个组件需要读取数据时,则采用缓存层的乐观处理方式,并配合快照和回滚功能。

相关阅读

  • React-Redux 如何决定重新渲染:选择器、相等性判断与类型化钩子 —— 一篇关于 React-Redux 内部机制的学习指南:Provider、useSelector 的相等性判断、已缓存的选择器、connect() 函数、带有 withTypes() 的类型化钩子,以及一些罕见的边缘情况。
  • 无需不同步的乐观 UI:快照、回滚与已取消的请求 —— 学习如何构建始终正确的乐观更新机制:状态快照、即时更新、失败时回滚、取消过时的请求,以及了解何时不应使用该模式。
  • 为加载、空状态、错误状态和重试状态设计 React 页面 — 学习如何构建 React 页面及表单可能出现的各种状态,涵盖骨架屏、数据重新获取、空结果处理、常见错误提示以及防止重复提交等功能。
  • 基于功能的 React 模块构建:使用 TanStack Query 实现文章 CRUD 功能 — 以功能而非文件类型来重构 React 应用,利用 TanStack Query 和 axios 实现完整的文章 CRUD 功能,并了解该架构在何种情况下无法进一步扩展。
  • 实时 Next.js 单体仓库中的 Zustand 与 TanStack Query 边界机制 — 学习如何在基于 Zustand、TanStack Query 和套接字的协作式 Next.js 单体仓库中,为 UI 状态、服务器状态及实时事件制定明确的职责划分规则。