React-Query-状态管理-全-

React Query 状态管理(全)

原文:zh.annas-archive.org/md5/7a61313ab658bb102a1a310c5d41c0f9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

状态管理是 React 生态系统中最热门的话题之一。在 React 中处理状态有许多库和工具,每个都带来了不同的配方和观点。有一点是明确的——处理客户端状态的状态管理解决方案并不擅长处理服务器状态。React Query 就是为了解决这个问题而创建的,以帮助管理你的服务器状态!

使用 React Query 进行状态管理中,你将获得一本指南,它将带你从零开始,到本书结束时成为 React Query 的专家。

你将从学习 React 世界中状态的历史背景以及是什么导致了从全局状态到客户端和服务器状态的转变开始。有了这些知识,你将理解 React Query 的需求。随着你通过章节的深入,你将学习 React Query 如何帮助你处理常见的服务器状态挑战,例如获取数据、缓存数据、更新数据以及将数据与服务器同步。

但这还不是全部——一旦你掌握了 React Query,你将学习如何将这一知识应用到服务器端渲染中。

最后,你将通过利用 Testing Library 和 Mock Service Worker 来学习一些测试代码的模式。

在本书结束时,你将获得对状态的新视角,并能够利用 React Query 解决应用程序中服务器状态的所有挑战。

本书面向对象

本书面向希望提高状态管理技能并开始处理服务器状态挑战,同时提升开发和 用户体验的 JavaScript 和 React 开发者。*

对 Web 开发、JavaScript 和 React 的基本了解将有助于理解本书中涵盖的一些关键概念。

本书涵盖内容

第一章什么是状态以及我们如何管理它?,涵盖了状态的基本定义,并给出了我们如何管理状态的历史概述。

第二章服务器状态与客户端状态对比,将状态概念分开,帮助我们理解为什么独立于客户端状态管理服务器状态如此重要。

第三章React Query – 介绍、安装和配置,介绍了 React Query 并提供将其添加到应用程序的方法。

第四章使用 React Query 获取数据,涵盖了如何利用useQuery自定义钩子获取你的服务器状态。

第五章更多数据获取挑战,扩展了前一章中介绍的概念,并涵盖了如何利用useQuery处理其他数据获取挑战。

第六章使用 React Query 执行数据突变,涵盖了如何利用useMutation自定义钩子对服务器状态进行更改。

第七章使用 Next.js 或 Remix 进行服务器端渲染,介绍了如何利用 React Query 与 Next.js 或 Remix 等服务器端框架。

第八章测试 React Query 钩子和组件,为你提供了可以将应用到你的应用程序中以测试你的组件和利用 React Query 的自定义钩子的实践和食谱。

第九章React Query v5 的变化是什么?,是一个附加章节,介绍了 TanStack Query 的 v5 版本对 React Query 的引入以及你需要更新应用程序的内容。

为了充分利用这本书

建议具备基本的 RESTful API 和 HTTP 方法知识。如果你想利用使用它的示例,则需要具备基本的 GraphQL 知识。

你需要了解一些关于 HTML 的基本概念。你还需要理解 JavaScript 以及其一些概念,特别是承诺。

最后,鉴于我们正在使用 React Hooks,了解它们的工作原理以及如何在你的 React 应用程序中使用它们非常重要。

本书涵盖的软件/硬件 操作系统要求
Yarn Windows, macOS, 或 Linux
pnpm Windows, macOS, 或 Linux
npm Windows, macOS, 或 Linux
JavaScript Windows, macOS, 或 Linux
React 16.8 Windows, macOS, 或 Linux
Remix Windows, macOS, 或 Linux
Next.js Windows, macOS, 或 Linux
React Testing Library Windows, macOS, 或 Linux
Mock Service Worker Windows, macOS, 或 Linux
TanStack Query Windows, macOS, 或 Linux

如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)访问代码。这样做将帮助你避免与代码的复制和粘贴相关的任何潜在错误。

本书为你提供了实践和工具,以全面理解和掌握 TanStack Query React 适配器——React Query。到本书结束时,你将具备充分利用它的必要理解,并准备好决定是否将其添加到你的项目中。

下载示例代码文件

你可以从 GitHub 下载这本书的示例代码文件:https://github.com/PacktPublishing/State-management-with-React-Query。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在 https://github.com/PacktPublishing/ 上找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色图像 PDF 文件。你可以从这里下载:packt.link/Wt1n6

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“React 原生地为我们提供了两种在应用程序中保持状态的方法 - useStateuseReducer。”

代码块设置如下:

const NotState = ({aList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}) => {
  const value = "a constant value";
  const filteredList = aList.filter((item) => item % 2 === 0);
  return filteredList.map((item) => <div key={item}>{item}</div>);
};

当我们希望将您的注意力引到代码块中的特定部分时,相关的行或项目将以粗体显示:

const App = () => {
  ...
  return (
    <div className="App">
      <div>Counter: {count}</div>
      <div>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>

任何命令行输入或输出都按以下方式编写:

npm i @tanstack/react-query

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“作为一个用户,我希望在点击无效****查询按钮时重新获取我的查询。”

小贴士或重要注意事项

它看起来像这样。

联系我们

我们始终欢迎读者的反馈。

请将邮件主题中提及书籍标题,并发送至customercare@packtpub.com

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将非常感激您向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

请通过copyright@packt.com发送带有材料链接的邮件。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。

分享您的想法

一旦您阅读了《使用 React Query 进行状态管理》,我们非常乐意听到您的想法!扫描下面的二维码直接进入此书的亚马逊评论页面并分享您的反馈。

二维码图片

packt.link/r/1-803-23134-3

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?

您的电子书购买是否与您选择的设备不兼容?

请放心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接

二维码图片

packt.link/free-ebook/9781803231341

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱

第一部分:理解状态和了解 React Query

状态是使您的应用程序运行的关键。我们很多人往往没有意识到的是,存在不同类型的状态。这些不同类型的状态在管理给定状态时会导致不同的挑战。在本部分,我们将更深入地了解状态以及我们如何管理它。在这个过程中,我们将了解到服务器和客户端状态具有非常不同的挑战,需要分别处理,并使用不同的工具。为了处理服务器状态,我们将学习更多关于 TanStack Query React 适配器 React Query 的知识,并了解如何将其添加到我们的应用程序中。

本部分包括以下章节:

  • 第一章什么是状态以及我们如何管理它?

  • 第二章服务器状态与客户端状态

  • 第三章React Query – 介绍、安装和配置

第一章:状态是什么以及我们如何管理它?

状态是一个可变的数据源,可以用于在 React 应用程序中存储数据,并且可以随时间变化,并可用于确定你的组件如何渲染。

本章将更新你对 React 生态系统中的状态的现有知识。我们将回顾它是什么以及为什么需要它,并了解它是如何帮助你构建 React 应用的。

我们还将回顾如何通过使用 useState 钩子、useReducer 钩子和 React Context 原生地管理状态。

最后,我们将简要介绍常见的状态管理解决方案,如 ReduxZustandMobX,并了解它们为什么被创建以及它们共有的主要概念。

到本章结束时,你将学习或记住关于状态的所有必要知识,以便继续阅读本书。你还会注意到不同状态管理解决方案之间状态管理的方式存在一种模式,或者重新认识一个熟悉的术语。剧透一下:它是全局状态。

在本章中,我们将涵盖以下主题:

  • React 中的状态是什么?

  • 在 React 中管理状态

  • 不同的状态管理库有什么共同之处?

技术要求

在本书中,你将看到一些代码片段。如果你想尝试它们,你需要以下工具:

  • 一种集成开发环境IDE)如 Visual Studio Code。

  • 一个网络浏览器(Google Chrome、Firefox 或 Edge)。

  • Node.js。本书中的所有代码都是使用当前 LTS 版本编写的(16.16.0)。

  • 一个包管理器(npm、Yarn 或 pnpm)。

  • 一个 React 项目。如果你没有,你可以在终端中运行以下命令来创建一个:

    npx create-react-app my-react-app
    

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_1

React 中的状态是什么?

状态是你的 React 应用的核心。

我挑战你尝试构建一个没有任何类型状态的 React 应用程序。你可能能够做些什么,但很快你就会得出结论,props 不能为你做所有事情,然后陷入困境。

如介绍中所述,状态是一个可变的数据源,用于存储你的数据。

状态是可变的,这意味着它可以随时间变化。当状态变量发生变化时,你的 React 组件将重新渲染以反映状态对 UI 造成的任何更改。

好的,现在,你可能想知道,“我在状态中会存储什么?”嗯,我遵循的一个经验法则是,如果你的数据符合以下任何一点,那么它就不是状态:

  • Props

  • 总是相同的 数据

  • 可以从其他状态变量或 props 推导出的数据

任何不符合此列表的内容都可以存储在状态中。这意味着像通过请求获取的数据、UI 的浅色或深色模式选项以及从 UI 表单中填写错误得到的错误列表等都是状态的例子。

让我们看看以下示例:

const NotState = ({aList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10
  ]}) => {
  const value = "a constant value";
  const filteredList = aList.filter((item) => item % 2 ===
    0);
  return filteredList.map((item) =>
    <div key={item}>{item}</div>);
};

这里,我们有一个名为 NotState 的组件。让我们看看里面的值,并使用我们的经验法则。

aList 变量是一个组件属性。由于我们的组件将接收这个属性,因此它不需要是状态。

我们的 value 变量被分配了一个字符串值。由于这个值始终是 常量,因此它不需要是状态。

最后,filteredList 变量是从我们的 aList 属性中派生出来的;因此,它不需要是状态。

现在你已经熟悉了状态的概念,让我们动手了解如何在 React 中管理它。

在 React 中管理状态

在深入一些示例之前,重要的是要提到,在这本书中,所有展示的示例都是 React 16.8 版本之后的版本。这是因为 React Hooks 是在这个版本中引入的。Hooks 改变了我们编写 React 代码的方式,并允许出现像 React Query 这样的库,因此任何展示的示例都利用了它们。

什么是 React Query?

React Query 是一个用于在 React 中获取、缓存和更新服务器状态的协议无关的钩子集合。

在本节中,我将向您展示 React 如何在组件中处理状态,以及如果我们需要在组件之间共享状态时应该做什么。

让我们考虑以下场景。

我想构建一个允许我计数的应用程序。在这个应用程序中,我想能够做以下事情:

  • 查看当前计数器值

  • 增加我的计数器

  • 减少我的计数器

  • 重置计数器

让我们想象我们有一个名为 App 的 React 组件:

const App = () => {
  ...
  return (
    <div className="App">
      <div>Counter: {count}</div>
      <div>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>

这个应用程序提供了处理我们的计数器需求所需的 UI,例如一个 div,我们应该用它来显示我们的 count,以及三个带有 onClick 事件的按钮,等待回调函数执行以下所需的每个动作。

我们只是缺少这个组件的核心,即状态。React 本地为我们提供了两种在应用程序中保存状态的方法:useStateuseReducer

让我们从查看 useState 开始。

使用 useState 管理状态

useState 是一个 React 钩子,允许你保存一个有状态值。当你调用这个钩子时,它将返回一个有状态值和一个用于更新它的函数。

让我们看看如何利用 useState 构建计数器应用的示例:

const App = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount((currentCount) =>
    currentCount + 1);
  const decrement = () => setCount((currentCount) =>
    currentCount - 1);
  const reset = () => setCount(0);
  return (
    <div className="App">
      <div>Counter: {count}</div>
      <div>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
};

前面的代码片段利用 useState 钩子来保存我们的计数器状态。当我们第一次调用 useState 时,会做两件事:

  • 状态值初始化为 0

  • count 状态变量被解构;然后,对状态更新函数,称为 setCount,也做了同样的处理

在此之后,我们声明函数,在其中我们使用状态更新函数setCount来增加、减少或重置我们的状态变量。

最后,我们将状态变量分配给相应的 UI 部分,并将回调传递给按钮的onClick事件。

通过这种方式,我们已经构建了一个简单的计数器应用。我们的应用将开始渲染计数为 0。每次我们点击按钮时,它将执行相应的状态更新,重新渲染我们的应用,并显示新的计数值。

useState是你在 React 应用中需要任何状态时最常用的答案。但别忘了在应用之前先应用“我在状态中要存储什么?”的经验法则!

现在,让我们看看如何使用useReducer钩子来管理状态并构建相同的计数器应用的例子。

使用 useReducer 管理状态

当我们有一个更复杂的状态时,useReducer是首选选项。在使用钩子之前,我们需要做一些设置,以便我们有发送到useReducer钩子所需的一切:

const initialState = { count: 0 };
const types = {
  INCREMENT: "increment",
  DECREMENT: "decrement",
  RESET: "reset",
};
const reducer = (state, action) => {
  switch (action) {
    case types.INCREMENT:
      return { count: state.count + 1 };
    case types.DECREMENT:
      return { count: state.count - 1 };
    case types.RESET:
      return { count: 0 };
    default:
      throw new Error("This type does not exist");
  }
};

在上述代码片段中,我们创建了三件事:

  • 一个initialState对象。该对象有一个属性 count,其值为0

  • 一个描述我们将支持的所有操作类型的types对象。

  • 一个reducer。这个 reducer 负责接收我们的状态和操作。通过匹配该操作与预期的类型,我们将能够更新状态。

现在设置完成,让我们创建我们的计数器:

const AppWithReducer = () => {
  const [state, dispatch] = useReducer(reducer,
    initialState);
  const increment = () => dispatch(types.INCREMENT);
  const decrement = () => dispatch(types.DECREMENT);
  const reset = () => dispatch(types.RESET);
  return (
    <div className="App">
      <div>Counter: {state.count}</div>
      <div>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
};

上述代码片段利用useReducer钩子来保存我们的计数器状态。当我们第一次调用useReducer时,会做三件事:

  • 我们向我们的钩子指示应该使用哪个reducer

  • 我们使用initialState对象初始化我们的状态。

  • 我们解构state对象和dispatch函数,这使得我们可以从useReducer钩子中分发操作。

在此之后,我们创建负责调用带有预期操作的dispatch函数的函数。

最后,我们将状态变量分配给相应的 UI 部分,并将回调传递给按钮的onClick事件。

现在你已经掌握了这两个钩子,你现在知道如何在组件中管理状态。

现在,让我们设想以下场景:如果你需要你的计数器状态在其他组件中可访问怎么办?

你可以通过 props 传递它们。但如果这个状态需要发送到树上的五个其他组件和不同级别呢?你会进行 prop-drilling 并将它传递给每个组件吗?

为了处理这种场景并提高代码的可读性,React Context被创建出来。

使用 React Context 共享状态

Context 允许你在不进行 prop-drilling 的情况下在组件之间原生地共享值。让我们学习如何构建一个上下文来处理我们的计数器:

import { useState, createContext } from "react";
export const CountContext = createContext();
export const CountStore = () => {
  const [count, setCount] = useState(0);
  const increment = () => setCount((currentCount) =>
    currentCount + 1);
  const decrement = () => setCount((currentCount) =>
    currentCount - 1);
  const reset = () => setCount(0);
  return {
    count,
    increment,
    decrement,
    reset,
  };
};
const CountProvider = (children) => {
  return <CountContext.Provider value={CountStore()}
    {...children} />;
};
export default CountProvider;

在上述代码片段中,我们做了三件事:

  • 使用createContext函数创建我们的上下文。

  • 创建一个useState钩子。在存储的末尾,我们返回一个包含执行状态更新和创建我们的状态变量的函数的对象。

  • 创建一个CountProvider。这个提供者负责创建一个将用于包裹组件的提供者。这将允许该提供者内部的每个组件都能访问我们的CountStore值。

一旦完成这个设置,我们需要确保我们的组件可以访问我们的上下文:

root.render(
  <CountProvider>
    <App />
  </CountProvider>
);

前面的代码片段利用了我们在前面的代码片段中创建的CountProvider来包裹我们的App组件。这允许App内部的每个组件都能消费我们的上下文:

import { CountContext } from "./CountContext/CountContext";
const AppWithContext = () => {
  const { count, increment, decrement, reset } =
    useContext(CountContext);
  return (
    <div className="App">
      <div>Counter: {count}</div>
      <div>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
};

最后,在这个代码片段中,我们利用了useContext钩子来消费我们的CountContext。由于我们的组件是在我们的自定义提供者中渲染的,因此我们可以访问上下文中的状态。

每当上下文中的状态更新时,React 将确保所有消费我们的上下文的组件都会重新渲染,并接收状态更新。这往往会导致不必要的重新渲染,因为如果你只消费状态中的一个变量,而由于某种原因另一个变量发生了变化,那么上下文将迫使所有消费者重新渲染。

上下文的一个缺点是,通常,无关的逻辑会聚集在一起。正如你可以从前面的代码片段中看到的那样,这会以牺牲一些样板代码为代价。

现在,上下文仍然很强大,这是 React 如何让你在组件之间共享状态的方式。然而,它并非一直存在,因此社区不得不想出如何实现状态共享的方法。为此,创建了状态管理库。

不同的状态管理库有什么共同之处?

React 为你提供的一个自由是,它不对你的开发强加任何标准或实践。虽然这很好,但它也导致了不同的实践和实现。

为了使这更容易,并为开发者提供一些结构,创建了状态管理库:

  • Redux提倡一个以存储、reducer 和选择器为重点的方法。这导致需要学习特定的概念,并在项目中填充大量可能影响代码可读性和增加代码复杂性的样板代码。

  • Zustand提倡使用自定义钩子方法,其中每个钩子都持有你的状态。这是迄今为止最简单的解决方案,也是我最喜欢的解决方案。它与 React 协同工作,并完全拥抱钩子。

  • MobX不强制架构,而是关注于函数式响应式方法。这导致了一些更具体的概念,实践方式的多样性可能导致开发者遇到与 React 中可能已经遇到的相同的代码结构问题。

所有这些库中有一个共同点,那就是它们都在尝试解决我们尝试用 React Context 解决的问题:管理我们的 共享状态 的方法

在 React 树内部可访问多个组件的状态通常被称为全局状态。现在,全局状态常常被误解,这导致你的代码中增加了不必要的复杂性,并且常常需要求助于本节中提到的库。

最后,每个开发者和团队都有自己的偏好和选择。考虑到 React 给你提供了处理状态的自由,你必须考虑每个解决方案的所有优缺点,在做出选择之前。从一个迁移到另一个可能需要大量时间,并完全改变你应用程序中处理状态的模式,所以请明智地选择,并留出足够的时间。

虽然全局状态不是 React Query 被构建的原因,但它对其创建有影响。全局状态通常的组成方式导致了管理其特定部分的需要,这部分有许多挑战。这个特定部分被称为服务器状态,而它历史上的处理方式为激励 Tanner Linsley 创建 React Query 铺平了道路。

摘要

在本章中,我们熟悉了状态的概念。到目前为止,你应该理解状态作为 React 应用程序核心的重要性,并知道如何借助useStateuseReducer原生化地管理它。

你了解到有时你需要与多个组件共享状态,你可以通过 Context 或利用第三方状态管理库来实现。每种解决方案都有其优缺点,最终将取决于开发者的个人偏好。

第二章《服务器状态与客户端状态》中,你将更深入地了解全局状态,并发现我们的全局状态通常是服务器和客户端状态的组合。你将学习这些术语的含义,如何识别这些状态,以及与它们相关的常见挑战。

第二章:服务器状态与客户端状态的比较

全局状态是我们看待状态最常见的方式。它是通过一个或多个组件在我们的应用程序中全局共享的状态。

我们通常不知道的是,在我们的日常开发中,我们的全局状态最终会在我们的应用程序外部持久化的状态和仅存在于我们应用程序内的状态之间分割。第一种类型的状态被称为服务器状态,而第二种类型的状态被称为客户端状态。这两种类型的状态都有它们特定的挑战,并需要不同的工具来帮助管理它们。

在本章中,我们将了解为什么我们主要将状态称为全局状态,以及为什么我们应该调整我们的思维模型以包括客户端和服务器状态。

我们还将回顾每种类型的状态负责什么,如何在应用程序中区分它们,以及理解导致 React Query 创建的挑战。

到本章结束时,你将能够通过应用你刚刚学到的思维模型,将全局状态完全分割成客户端状态和服务器状态。

你还将了解在应用程序中拥有服务器状态所创造的所有挑战,并准备好用 React Query 克服它们。

在本章中,我们将涵盖以下主题:

  • 什么是全局状态?

  • 什么是客户端状态?

  • 什么是服务器状态?

  • 理解与服务器状态相关的常见挑战

技术要求

本章的所有代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_2

什么是全局状态?

当我们在 React 世界中开始状态管理时,我们通常不熟悉不同的概念。

通常,我们只是通过思考我们在组件中拥有的useStateuseReducer钩子的数量来查看状态。然后,当useStateuseReducer模式停止工作,我们需要在更多组件之间共享状态时,我们要么将状态提升到最近的父组件,当这个状态只需要该组件的子组件时,要么找到一个共同的地方,这个状态可以存在,并且所有我们想要的组件都可以访问它。这种状态通常被称为全局状态。

让我们看看一个应用程序中全局状态可能是什么样子的例子。在这里,我们有一个负责管理主题选择、获取数据和跟踪此获取请求加载状态的商店:

const theme = {
  DARK: "dark",
  LIGHT: "light",
};
export const GlobalStore = () => {
  const [selectedTheme, setSelectedTheme] = useState
    (theme.LIGHT);
  const [serverData, setServerData] = useState(null);
  const [isLoadingData, setIsLoadingData] = useState
    (false);
  const toggleTheme = () => {
    setSelectedTheme((currentTheme) =>
      currentTheme === theme.LIGHT ? theme.DARK :
        theme.LIGHT
    );
  };
  const fetchData = (name = "Daniel") => {
    setIsLoadingData(true);
    fetch(`<insert_url_here>/${name}`)
      .then((response) => response.json())
      .then((responseData) => {
        setServerData(responseData);
      })
 .finally(() => {
        setIsLoadingData(false);
      })
      .catch(() => setIsLoadingData(false));
  };
  useEffect(() => {
    fetchData();
  }, []);
  return {
    selectedTheme,
    toggleTheme,
    serverData,
    isLoadingData,
    fetchData
  };
};

这个片段展示了某些典型全局状态的一个示例。通过使用 React Context,我们创建了一个包含以下内容的商店:

  • 一个名为selectedTheme的状态变量,用于管理所选主题

  • 一个名为serverData的状态变量,用于显示从我们的 API 请求返回的数据

  • 一个名为isLoadingData的状态变量,用于显示我们的 API 请求当前加载状态是否仍在加载。

  • 一个名为toggleTheme的函数,允许我们在浅色和深色模式之间切换。

  • 一个fetchData函数,允许我们获取给定数据并设置我们的加载状态为truefalse,这取决于请求的状态。

  • 一个useEffect钩子,它将触发初始数据获取以提供我们的serverData状态。

useEffect 是什么?

useEffect是一个 React 钩子,允许你在组件中执行副作用。

所有这些都是从我们的存储中返回的,以便上下文的消费者可以在整个应用程序中访问它们,只要他们订阅我们的上下文。

从第一眼看来,这个状态似乎没有问题,并且可能对大多数应用程序来说已经足够了。问题是,大多数时候,这个状态会因为新的开发需求而增长。这通常会导致我们的状态大小增加。

现在,让我们设想我们需要一个次要主题,并且需要添加另一个名为secondaryTheme的状态变量。我们的代码看起来会像这样:

const [selectedTheme, setSelectedTheme] = useState(theme.LIGHT);
const [secondaryTheme, setSecondaryTheme] = useState(theme.LIGHT);
…
  const toggleSecondaryTheme = () => {
    setSecondaryTheme((currentTheme) =>
      currentTheme === theme.LIGHT ? theme.DARK :
        theme.LIGHT
    );
  };
  const toggleTheme = () => {
    setSelectedTheme((currentTheme) =>
      currentTheme === theme.LIGHT ? theme.DARK :
        theme.LIGHT
    );
  };

因此,在这个片段中,我们添加了我们的secondaryTheme状态变量,它的工作方式非常类似于selectedTheme

现在,我们在这里使用上下文;这意味着每次我们触发状态更新时,任何消费这个状态的组件都将被迫重新渲染以接收新的状态更新。这对我们意味着什么?

让我们设想我们有两个组件(让我们称它们为组件 A组件 B)正在消费这个上下文,但组件 B只解构selectedTheme状态,而组件 A解构一切。如果组件 AsecondaryTheme上触发状态更新,那么组件 B也将重新渲染,因为 React 注意到了它们共享的上下文中的更新。

这就是 React Context 的工作方式,我们无法改变这一点。我们可以争论,我们可以要么分割上下文,要么将订阅的组件分割成两个组件,并将第二个组件包裹在memo中,或者只是将我们的返回包裹在useMemo钩子中。当然,这可能会解决我们的问题,但我们只是在处理一种创建全局状态的状态类型的变化。

memo 和 useMemo 是什么?

memo是一个你可以将其包裹在组件中来定义其记忆化版本的函数。这将保证你的组件只有在它的属性发生变化时才会重新渲染。

useMemo是一个 React 钩子,允许你记忆化一个值。通常,我们想要记忆化的值是昂贵计算的结果。

现在,假设我们需要添加另一个 API 请求上下文。同样,上下文增长,我们最终会遇到与主题相同的问题。

如您现在可能已经理解的那样,状态组织有时可能是一个噩梦。我们可以求助于第三方库来帮助我们,但,再次强调,这仅仅是我们状态问题的一小部分。

到目前为止,我们只处理状态的组织,但现在想象一下,我们需要缓存我们从 API 请求中获得的数据。这可能会让我们陷入疯狂。

从我们刚刚注意到的问题中,我们可以看到,在我们的全局状态中,我们往往面临不同的挑战,一个解决方案可能对某件事有效,但对另一件事可能无效。这就是为什么分割我们的全局状态很重要。我们的全局状态通常是客户端状态和服务器状态的混合。在接下来的章节中,你将了解这些状态中的每一个是什么,我们将专注于服务器状态,最终理解为什么 React Query 如此受欢迎,并使我们的开发者生活变得更加容易。

客户端状态是什么?

我知道,到现在为止,你一定在想,这本书什么时候会开始介绍 React Query?我们几乎到了,我向你保证。我只是需要你完全理解为什么我如此热爱 React Query,而要做到这一点,了解它解决的主要问题非常重要。

现在,客户端状态不是它解决的问题之一,但你必须能够在作为开发者的日常工作中识别客户端状态,以便你完全理解应该由 React Query 管理什么,应该由其他状态管理工具管理什么。

客户端状态是应用程序拥有的状态。

这里有一些有助于定义你的客户端状态的东西:

  • 这种状态是同步的,这意味着你可以无需等待时间,通过使用同步 API 来访问它。

  • 它是局部的;因此,它只存在于你的应用程序中。

  • 它是临时的,所以页面刷新时可能会丢失,并且在会话之间通常是非持久的。

带着这些知识,如果你回顾一下GlobalStore,你会把什么识别为属于客户端状态?

可能只有selectedTheme,对吧?

让我们应用从上一个要点中学到的知识:

  • 我们需要等待获取它的值吗?,这意味着它是同步的。

  • selectedTheme只存在于我们的应用程序中吗?是的

  • 它会在页面刷新时丢失吗?是的,如果我们不在本地存储中持久化它或检查浏览器首选项,那么它的值将在页面刷新之间丢失。

考虑到这一点,我们可以肯定地说selectedTheme属于我们的客户端状态。

为了管理这种类型的状态,我们可以使用从 React Context 到 Redux、Zustand 或 MobX 等第三方库的任何东西,当事情开始变得难以组织和维护时。

如果我们对serverData状态变量提出相同的问题,它会产生相同的效果吗?

  • 数据只存在于我们的应用程序中吗?,它存在于某个地方的数据库中。

  • 它会在页面刷新时丢失吗?,数据库仍然保留着数据,所以当我们重新加载时,它将再次被检索。

  • 我们需要等待获取它吗?是的,我们需要触发一个获取此数据的请求。

这意味着我们的serverData状态变量不属于我们的客户端状态。这是我们将其归类为服务器状态的一部分。

现在,让我们谈谈让你来到这本书并使 React Query 变得必要的那个东西。

服务器状态是什么?

我们在应用程序中始终有服务器状态。主要问题是,我们试图将其与我们的客户端状态管理解决方案结合起来。试图将我们的服务器状态与我们的客户端状态管理解决方案结合的一个常见例子是使用Redux SagaRedux Thunk。它们都使得进行数据获取和存储服务器状态变得更容易。主要问题开始于我们必须处理服务器状态带来的挑战,但让我们不要走得太远;你将在下一节中了解这些挑战。

现在,你可能想知道,服务器状态是什么?

好吧,正如其名所示,服务器状态是存储在您的服务器上的状态类型。以下是一些有助于识别您的服务器状态的事情:

  • 这个状态是异步的,这意味着你需要使用异步 API 来获取和更新它。

  • 它被远程持久化——大多数情况下是在数据库或您不拥有或控制的外部位置。

  • 在你的应用程序中,这个状态不一定是最新的,因为大多数情况下,你拥有它的共享所有权,它可能被其他人改变,他们也在消费它。

带着这些知识,让我们回顾一下GlobalStore和我们的serverData状态变量,并应用这些规则来识别我们的服务器状态:

  • 我们需要异步 API 来访问这个状态吗?我们需要!我们需要向服务器发送一个获取请求并等待它发送数据回来。

  • 它是否被远程持久化?当然是的。就像我在上一个要点中说的那样,我们需要向我们的服务器请求它。

  • 这个状态在我们应用程序中总是最新的吗?我们不知道。我们无法控制状态。这意味着如果任何消费相同 API 的人决定更新它,那么我们的serverData状态变量将立即过时。

现在,你可能正在回顾GlobalStore并思考以下问题:如果selectedTheme是客户端状态,而data是服务器状态,那么isLoadingData状态变量是什么呢?

好吧,这是一个派生状态变量。这意味着它的状态将始终取决于我们当前serverData获取请求的状态。如果我们获取数据,那么isLoadingData将是true;一旦我们完成数据获取,那么isLoadingData将回到false

现在,想象一下,在你的应用程序中,每种服务器状态变量都需要一个这样的派生状态变量。我还要让你想象一个场景,在这个场景中,你需要处理获取请求失败时的错误。你可能为错误创建另一个状态变量,对吧?但你不最终会遇到和加载状态相同的问题吗?

之前提到的场景只是服务器状态带给你的应用程序挑战的冰山一角。想象一下,你的团队技术负责人有一天来到办公室告诉你,现在你需要开始缓存数据;哦,上帝,我们还没有考虑到的另一个挑战。正如你所见,服务器状态有许多挑战,在下一节中,我们将看到其中的一些。

理解服务器状态中的常见挑战

到现在为止,你可能已经意识到服务器状态带来了相当多的问题。这些挑战使得 React Query 在发布时更加突出,因为它以如此简单的方式解决了这些问题,以至于看起来太好了而不像是真的。

现在,这些挑战是什么,为什么它们大多数时候都如此复杂难以解决?

在本节中,我们将看到我们与服务器状态相关的所有常见挑战,并了解我们在 React Query 出现之前作为开发者必须自己解决的一些难题。

缓存

这可能是我们在服务器状态管理中面临的最具挑战性的问题之一。

为了提高页面性能并使你的网站更具响应性,你通常需要缓存你的数据。这意味着能够重用你之前获取的数据,以避免再次从服务器获取。

现在,你可能认为这听起来很简单,但考虑以下事项:

  • 在保持应用程序响应的同时,你需要在后台更新你的缓存。

  • 你需要能够评估你的缓存数据何时变得过时并需要更新。

  • 一旦数据有一段时间未被访问,你必须回收这些数据。

  • 在获取数据之前,你可能希望用一些模板数据初始化你的缓存。

如你所见,缓存带来了它应有的问题,想象一下你必须自己解决所有这些问题。

乐观更新

在执行变更时,你通常希望提升用户体验。变更是一个请求,它将创建或更新你的服务器状态。有时,你希望提升用户体验。我们都讨厌填写表格,然后看着加载指示器,同时我们的应用程序在后台执行变更、重新获取数据并更新用户界面。

为了提升用户体验,我们可以求助于乐观更新。

乐观更新是指在变更进行中时,我们更新我们的用户界面以显示变更完成后将如何显示,尽管那个变更尚未被确认完成。基本上,我们是乐观的,认为这些数据将改变,并在变更后成为我们期望的样子,这样我们就可以为用户节省一些时间,并给他们一个他们最终会看到的用户界面。

现在,想象一下实现这一点。在进行变更时,你需要以我们期望变更成功后的方式更新应用程序中的服务器状态。这将使 UI 对用户更加响应,他们可以更早地与之交互。变更成功后,你需要重新触发手动重新获取服务器状态,以便你实际上在应用程序中拥有更新的状态。现在,想象一个变更失败的场景。你需要手动将状态回滚到乐观更新之前的版本。

乐观更新为用户提供了一个惊人的用户体验,但管理所有成功和错误场景,以及保持服务器数据更新,可能是一件困难的事情。

去重请求

让我们描绘以下场景。

你在 UI 中有一个按钮,当用户点击时,会触发一个获取请求以部分更新你的服务器状态。在获取操作进行时,按钮被禁用。

这可能看起来没问题,一点也不麻烦,但想象一下,在你加载状态更新和你的按钮最终被禁用之前,用户可以点击按钮 10 次。你得到了什么?应用程序中针对相同数据的 10 次额外的意外请求。

这就是为什么去重请求很重要。当获取相同类型的数据时,如果我们触发了针对相同数据的多个请求,我们只想发送其中一个请求,并避免用不必要的请求污染用户的网络。

现在,想象一下你需要自己实现这一点。你需要了解应用程序中当前正在进行的所有请求。当其中一个请求与另一个请求完全匹配时,你需要取消第二个、第三个或第四个请求。

性能优化

有时,你可能需要在服务器状态中做一些额外的性能优化。以下是一些你可能需要用于特定服务器状态管理的优化模式。

  • 延迟加载:你可能只想在满足特定条件时执行一次特定的数据获取请求。

  • 无限滚动:当处理大量列表时,无限滚动是一种非常常见的模式,你只是逐渐将更多数据加载到你的服务器状态中。

  • 分页数据:为了帮助结构化大型数据集,你可以选择分页你的数据。这意味着每当用户决定从第 1 页移动到第 2 页时,你需要获取该页面对应的数据。

正如你所见,我们需要解决几个挑战,才能在我们的应用程序中拥有我们认为是处理服务器状态的最佳体验。

问题在于,作为开发者,决定自己处理这些挑战可能需要相当长的时间,而我们最终创建的代码往往容易出错。大多数情况下,这些实现最终会影响我们代码的可读性,并显著增加理解我们项目所需的复杂性。

如果我告诉你,有一种东西可以在后台为你处理所有这些挑战,同时给你一个超级干净、简单的 API,这将使你的代码更易于阅读、理解,并让你感觉自己是一位真正的服务器状态大师,你会怎么想?

如果你正在阅读这本书,那么你可能已经知道了答案。是的,我正在谈论 React Query。

因此,打包好你的服务器状态知识,准备好你的项目,因为从下一章开始,我们将改变你处理服务器状态的方式。

摘要

在本章中,我们完全理解了全局状态的概念。到现在为止,你应该能够理解为什么我们的状态经常被称为全局状态,以及如果我们不将其拆分,维护它可能会变得多么困难。

你已经学会了如何将你的状态分为客户端和服务器端状态,并理解了每种类型的状态对于你的应用的重要性,以及如何在你的代码中识别它们。

最后,你已经熟悉了服务器状态可能给你的应用带来的挑战,并理解了如果你要自己解决所有这些问题,那么你的代码复杂性将会显著增加,你可能会失去一些非常需要的睡眠时间。

第三章《React Query – 介绍、安装和配置》中,你将开始亲身体验 React Query。你将了解它是什么,以及它是如何帮助你摆脱服务器状态给应用带来的所有烦恼。你将学习如何为你的应用安装和配置它,以及如何添加专门的 React Query 开发者工具,使你的开发生活更加轻松。

第三章:React Query – 介绍、安装和配置

React Query 是一个库,旨在让 React 开发者更容易地管理他们的服务器状态。它使得开发者能够克服与服务器状态相关的所有挑战,同时使他们的应用程序更快、更容易维护,并减少代码中的许多行。

在本章中,你将了解 React Query 并了解为什么它被创建。

你还将了解 React Query 的主要概念——查询突变

一旦你了解了 React Query,我们将在我们的应用程序中安装它,并确定我们需要在代码中进行的初始配置,以便完全使用它。

在本章结束时,你将了解所有关于 React Query Devtools 的内容,以便在使用 React Query 时拥有更好的开发者体验。

在本章中,我们将涵盖以下主题:

  • 什么是 React Query?

  • 安装 React Query

  • 配置 React Query

  • 将 React Query Devtools 添加到你的应用程序

技术要求

在本章中,我们将向我们的应用程序添加 React Query v4。为此,我们需要做几件事情:

  • 你的浏览器需要与以下配置兼容:

    • Google Chrome 版本需要至少为 73

    • Mozilla Firefox 版本需要至少为 78

    • Microsoft Edge 版本需要至少为 79

    • Safari 版本需要至少为 12.0

    • Opera 版本需要至少为 53

  • 版本 16.8 之后的 React 项目

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_3

什么是 React Query?

React Query 是一个协议无关的钩子集合,用于在 React 中获取、缓存和更新服务器状态。

它是由 Tanner Linsley 创建的,是名为 TanStack 的一系列开源库的一部分。

默认情况下,React Query 也可以与 React Native 无缝协作,并且它是用 TypeScript 编写的,这样你可以从所有其优势中受益,例如类型缩小和类型推断。

自版本 4 以来,React Query 已嵌入到名为 TanStack Query 的一系列库中。TanStack Query 使得将 React Query 的所有惊人功能传播到其他框架和库(如 Vue、Solid 和 Svelte)成为可能。

React Query 利用查询和突变来处理你的服务器状态。在阅读最后一句话后,你可能会想知道查询和突变是什么。我将在后续章节中展示一些代码,以便你可以看到 React Query 如何处理它们,但首先,让我们了解查询和突变。

查询

查询是你向异步源发出的请求,以获取你的数据。只要你有触发数据获取请求的函数,你就可以在 React Query 中执行查询。

通过允许我们将请求包裹在返回 promise 的函数中,React Query 支持 REST、GraphQL 以及任何其他异步数据获取客户端。

在 React Query 中,useQuery 自定义钩子允许你订阅查询。

突变

突变是一种操作,允许你创建、更新或删除你的服务器状态。

与查询一样,只要你有触发突变的函数,React Query 就支持 REST、GraphQL 以及任何其他异步数据获取客户端。

在 React Query 中,useMutation 自定义钩子允许你执行突变。

React Query 如何解决我的服务器状态挑战?

如果我告诉你,前一章中提出的所有挑战都可以通过 React Query 解决,你会怎么想?

无需配置,React Query 支持以下所有令人惊叹的功能:

  • 缓存:在每次查询之后,数据将在可配置的时间内被缓存,并且可以在整个应用程序中重复使用。

  • 查询取消:你的查询可以被取消,你可以在取消后执行一个操作。

  • 乐观更新:在突变过程中,你可以轻松地更新你的状态,以便为用户提供更好的用户体验。如果突变失败,你还可以轻松地回滚到之前的状态。

  • 并行查询:如果你需要同时执行一个或多个查询,你可以轻松地做到这一点,而不会对你的缓存造成任何影响。

  • 依赖查询:有时,我们需要在另一个查询完成后执行一个查询。React Query 使这变得简单,并避免了链式 promise。

  • 分页查询:使用 React Query 可以使这种 UI 模式变得更加简单。你会发现使用分页 API、切换页面和渲染获取的数据非常简单。

  • 无限查询:React Query 使这种 UI 模式变得更加简单。你可以将无限滚动实现到你的 UI 中,并且可以信任 React Query 在获取数据时使你的生活变得更简单。

  • 滚动恢复:你是否曾经从一个页面导航出去,然后当你导航回来时,发现页面滚动到了你离开时的确切位置?这是滚动恢复,只要你的查询结果被缓存,它就会自动工作。

  • 数据重新获取:需要触发数据重新获取吗?React Query 允许你通过几乎一行代码就能做到这一点。

  • 数据预获取:有时,你可以提前识别出用户的需求和后续操作。当这种情况发生时,你可以信任 React Query 在此之前帮助你预获取那些数据并为你缓存它们。这样,你的用户体验将得到改善,并且用户会感到更加满意。

  • 跟踪网络模式和离线支持:你是否曾经遇到过用户在使用你的应用程序时丢失互联网连接的情况?别担心,因为 React Query 可以跟踪你的网络当前状态,如果查询失败是因为用户失去了连接,那么一旦网络恢复,它将重试。

看着这个列表真是太棒了,对吧?

只要有开箱即用的缓存,这绝对是一个节省时间的超级好方法,因为当处理服务器状态时,这绝对是最难实现的事情之一。

在 React Query 之前,处理我们应用程序中的服务器状态要困难得多。我们尝试过,但我们的解决方案最终变得更加复杂,代码的可维护性更低。通常,这些实现甚至会影响用户体验,因为我们的应用程序会变得不那么响应。

使用 React Query,你现在能够大大减少代码中的行数,使你的应用程序更容易阅读和简单,同时,使你的应用程序更快、更响应。

我现在不会深入更多技术细节,因为,希望在下章中,你会看到所有这些功能的工作,并开始理解为什么 React Query 使你的生活变得如此简单。

现在,让我们先在我们的应用程序中安装 React Query。

安装 React Query

现在你已经了解了 React Query,你可能正在想,“哇,我真的很需要把这个添加到我的项目中。”别再等了——这就是你需要做的来安装 React Query。

根据你的项目类型,你可以以几种方式安装 React Query。

npm

如果你正在你的项目中运行 npm,那么这是你需要做的来安装 React Query。

在你的终端中,运行以下命令:

npm i @tanstack/react-query

Yarn

如果你更喜欢 Yarn,那么这是你需要做的来安装 React Query。

在你的终端中,运行以下命令:

yarn add @tanstack/react-query

pnpm

如果你是一位新包管理器的粉丝,比如 pnpm,并且正在你的项目中使用它,那么你需要这样做来安装 React Query。

在你的终端中,运行以下命令:

pnpm add @tanstack/react-query

脚本标签

没有使用包管理器?别担心,因为你可以通过使用托管在 内容 分发网络 上的全局构建来将 React Query 添加到你的应用程序中。

内容分发网络 (CDN)

CDN 是一组地理上分布的服务器集合,它们协同工作以允许在互联网上更快地交付内容。

要将 React Query 添加到你的应用程序中,在你的 HTML 文件末尾添加以下 script 标签:

<script src="img/index.production.js"></script>

现在你应该在项目中安装了 React Query。

现在,我们需要在我们的项目中进行初始配置,以便能够使用 React Query 的所有核心功能。

配置 React Query

React Query 具有非常快速和简单的配置。这提高了开发者的体验,并可以让你尽快开始将你的服务器状态迁移到 React Query。

要将 React Query 添加到您的应用程序中,您只需要了解两件事:

  • QueryClient

  • QueryClientProvider

QueryClient

如您现在所应知道的,缓存是 React Query 为开发者简化的重要事情之一。在 React Query 中,有两种机制用于处理此缓存,称为 QueryCacheMutationCache

QueryCache 负责存储与您的查询相关的所有数据。这可以是您的查询数据以及其当前状态。

MutationCache 负责存储与您的突变相关的所有数据。这可以是您的突变数据以及其当前状态。

为了让开发者更容易从这两个缓存中抽象出来,React Query 创建了 QueryClient。这是开发者与缓存之间的接口。

当您使用 React Query 设置应用程序时,您应该做的第一件事是创建一个 QueryClient 实例。为此,您需要从 @tanstack/react-query 包中导入它并实例化它:

import {
 QueryClient,
} from '@tanstack/react-query'
const queryClient = new QueryClient()

在前面的代码片段中,我们创建了一个新的 QueryClient 对象。由于我们在实例化对象时没有传递任何参数,因此 QueryClient 将假定所有默认值。

在创建我们的 QueryClient 时,我们可以作为参数发送四个选项。它们如下所示:

  • queryCache:此客户端将在整个应用程序中使用查询缓存。

  • mutationCache:此客户端将在整个应用程序中使用突变缓存。

  • logger:此客户端将使用它来显示错误、警告以及调试时有用的信息。如果没有指定任何内容,React Query 将使用控制台对象。

  • defaultOptions:所有查询和突变将在整个应用程序中使用的默认选项。

现在,您可能想知道何时应该手动设置这些参数而不是使用默认值。以下的小节将告诉您何时这样做。

QueryCache 和 MutationCache

这里有一个小小的预告,希望您在接下来的章节中能够复习并更好地理解,但了解何时手动配置 QueryCacheMutationCache 是非常重要的——所有查询和突变都可以在出现错误或执行成功时执行一些代码。这些代码由 onSuccessonError 函数表示。此外,在突变的情况下,您还可以在突变执行之前执行一些代码。在这种情况下,表示这个功能的函数被称为 onMutate

QueryCache 的情况下,它看起来是这样的:

import { QueryCache } from '@tanstack/react-query'
const queryCache = new QueryCache({
 onError: error => {
  // do something on error
 },
 onSuccess: data => {
  // do something on success
 }
})

在解释前面的代码片段之前,让我们先看看与之非常相似的 MutationCache

import { MutationCache } from '@tanstack/react-query'
const mutationCache = new MutationCache({
 onError: error => {
  // do something on error
 },
 onSuccess: data => {
  // do something on success
 },
 onMutate: newData => {
  // do something before the mutation
 },
})

如您所见,这两个代码片段非常相似,只是在 MutationCache 上的 onMutate 函数有所不同。

默认情况下,这些函数没有任何行为,但如果出于某种原因,你打算在执行突变或查询时始终执行某些操作,那么你可以在实例化缓存对象时,在相应对象的相应函数中进行此配置。

然后,你可以在实例化QueryClient时将此对象发送给它:

const queryClient = new QueryClient({
 mutationCache,
 queryCache
})

在前面的代码片段中,我们使用我们的自定义MutationCacheQueryCache函数实例化了一个新的QueryClient

Logger

你在你的项目中是否在console对象之外使用logger?那么,你可能想在QueryClient中配置它。

这里你需要做的是:

const logger = {
   log: (...args) => {
     // here you call your custom log function
   },
   warn: (...args) => {
     // here you call your custom warn function
   },
   error: (...args) => {
     // here you call your custom error function
   },
 };

在前面的代码片段中,我们创建了一个logger对象。这个对象有三个函数,React Query 将在需要记录错误、警告错误或显示错误时调用这些函数。你可以覆盖这些函数并添加你自己的自定义记录器。

然后,你所需要做的就是在你实例化QueryClient时传递这个logger对象:

const queryClient = new QueryClient({
 logger
})

在前面的代码片段中,我们使用我们的自定义记录器实例化了一个新的QueryClient

defaultOptions

有一些选项被用作你在整个应用程序中执行的所有突变或查询的默认值。defaultOptions允许你覆盖这些默认值。有很多默认值,我会避免展示所有这些,以免泄露下一章的内容,但请放心——在适当的时候,我会对这些选项进行回调。

这里是如何覆盖你的defaultOptions

const defaultOptions = {
   queries: {
     staleTime: Infinity,
   },
 };

在前面的代码片段中,我们创建了一个defaultOptions对象,并在其中创建了一个queries对象。在这个queries对象内部,我们指定了所有查询的staleTime都将设置为Infinity。再次提醒,不要担心现在还没有对这个定义,你将在下一章中理解它。

一旦完成这个设置,你所需要做的就是在你实例化QueryClient时传递这个defaultOptions对象,这样所有的查询都将具有staleTime属性并设置为Infinity

这里是如何操作的:

const queryClient = new QueryClient({
 defaultOptions
})

在前面的代码片段中,我们使用我们的自定义defaultOptions对象实例化了一个新的QueryClient

好的,所以现在你应该已经了解了QueryClient及其在 React Query 中作为大脑的角色。

所以,你可能正在想,考虑到 React Query 是基于钩子进行查询和突变的,我们是否需要始终将我们的QueryClient传递给所有的钩子?

想象一下如果是这种情况!在我们使用第二个或第三个钩子之前,我们都会对应用程序中的所有属性钻探感到厌烦。

让我们看看 React Query 通过引入QueryClientProvider如何帮助我们节省时间。

QueryClientProvider

为了让每个开发者更容易地共享我们的 QueryClient,React Query 采用了我们在 第一章 中学到的某种方法,那就是 React Context。通过创建其自定义提供者 QueryClientProvider,React Query 允许您与它自动提供的所有自定义钩子共享 QueryClient

下面的代码片段展示了如何使用 React Query 的 QueryClientProvider

import {
 QueryClient,
 QueryClientProvider,
} from '@tanstack/react-query'
// Create a client
const queryClient = new QueryClient()
const App = () => {
 return (
   <QueryClientProvider client={queryClient}>
     <Counter />
   </QueryClientProvider>
 )
}

正如您在前面的代码片段中所看到的,您需要做的就是从 @tanstack/react-query 包中导入您的 QueryClientProvider,用它包裹您的主体组件,并将其作为属性传递给 queryClient

您的应用程序现在已准备好开始使用 React Query。

现在,让我们看看如何添加和使用 React Query 专用的开发者工具。

添加 React Query Devtools

在调试我们的应用程序时,我们经常发现自己在想,如果有一种方法可以可视化应用程序内部发生的事情,那会多么美妙。好吧,有了 React Query,您不必担心,因为它有自己的开发者工具,或者称为 devtools。

React Query Devtools 允许您查看和理解所有查询和突变当前的状态。这将为您节省大量调试时间,并避免在所有代码中污染不必要的日志函数,即使只是暂时性的。

根据项目类型,您可以通过几种方式安装 React Query Devtools:

  • 如果您在项目中运行 npm,请运行以下命令:

    npm i @tanstack/react-query-devtools
    
  • 如果您正在使用 Yarn,请运行以下命令:

    yarn add @tanstack/react-query-devtools
    
  • 如果您正在使用 pnpm,请运行以下命令:

    pnpm add @tanstack/react-query-devtools
    

现在,您应该在您的应用程序中安装了 React Query Devtools。现在,让我们看看如何将它们添加到我们的代码中。

使用 Devtools 有两种方式。它们是浮动模式和嵌入式模式。

浮动模式

浮动模式将在屏幕角落浮动显示 React Query 标志。通过点击它,您可以切换 Devtools 的开启或关闭。

将显示在您屏幕角落的标志如下:

图片 3.1

图 3.1 – React Query Devtools 的标志

一旦切换,您将看到 Devtools:

图片 3.2

图 3.2 – React Query Devtools 的浮动模式

Devtools 将在您的 DOM 树中作为单独的 HTML 元素渲染。

图片 3.3

图 3.3 – React Query Devtools 的浮动模式在 DOM 上的显示

要将 Devtools 以浮动模式添加到您的应用程序中,您需要导入它:

import { ReactQueryDevtools } from '@tanstack/
  react-query-devtools'

导入后,只需将其添加到尽可能靠近您的 QueryClientProvider 的位置:

   <QueryClientProvider client={queryClient}>
     <ReactQueryDevtools initialIsOpen={false} />
     <Counter />
   </QueryClientProvider>

嵌入式模式

嵌入式模式会将 Devtools 嵌入为应用程序中的常规组件。

这是它在您的应用程序中的样子:

图片 3.4

图 3.4 – React Query Devtools 的嵌入式模式

如果您查看您的 DOM 树,您将看到 Devtools 被像常规组件一样渲染。

图片 3.5

图 3.5 – React Query Devtools 的嵌入式模式在 DOM 上的显示

要在你的应用程序中使用嵌入式模式的 Devtools,你需要导入它:

import { ReactQueryDevtoolsPanel } from '@tanstack/
  react-query-devtools'

一旦它们被导入,只需将它们添加到尽可能靠近你的 QueryClientProvider 的位置:

   <QueryClientProvider client={queryClient}>
     <ReactQueryDevtoolsPanel />
     <Counter />
   </QueryClientProvider>

默认情况下,Devtools 不包含在生产构建中。尽管如此,你可能会想在生产环境中加载它们以帮助调试某些问题。在下一节中,我们将看到如何做到这一点。

启用生产构建中的 Devtools

如果你决定在生产环境中加载 Devtools,你必须延迟加载它,而不是动态加载。这很重要,可以帮助减少你的应用程序包大小。同样重要的是懒加载 Devtools,因为当我们在生产环境中使用我们的应用程序时,我们可能永远不想使用它,所以我们想避免在我们的构建中添加最终根本不会使用的东西。在 React 中,我们可以使用 React.lazy 来懒加载组件。

这是我们可以使用 React.lazy 导入 Devtools 的方法:

const ReactQueryDevtoolsProduction = React.lazy(() =>
  import('@tanstack/react-query-devtools/build/lib/
    index.prod.js').then(
    (d) => ({
      default: d.ReactQueryDevtools,
    }),
  ),
)

前面的代码片段包裹了一个 React.lazy 并将承诺的返回值赋给 ReactQueryDevtoolsProduction,这样我们就可以在我们的生产环境中懒加载它,而不会增加我们的包大小。

什么是动态导入?

动态导入允许你从代码中的任何位置异步加载一个模块。此导入将返回一个承诺,当承诺被满足时,返回一个包含模块导出的对象。

前面的代码片段应该适用于所有打包器。如果你使用的是一个支持包导出的更现代的打包器,那么你可以像这样动态导入你的模块:

const ReactQueryDevtoolsProduction = React.lazy(() =>
  import('@tanstack/react-query-devtools/production').then(
    (d) => ({
      default: d.ReactQueryDevtools,
    }),
  ),
)

在这个代码片段中,我们将导入模块的路径从我们将要导入的路径更改为一个可以与更现代的打包器一起工作的路径。

当使用 React.lazy 并尝试渲染我们刚刚懒加载的组件时,React 要求该组件应该被一个 Suspense 组件包裹。这在我们要在懒加载的组件待定期间显示回退内容的情况下非常重要。

什么是悬念?

Suspense 允许你在组件内部尚未准备好渲染时,在你的 UI 中显示加载指示。

让我们看看我们需要做什么来加载我们的 ReactQueryDevtoolsProduction 组件:

<React.Suspense fallback={null}>
  <ReactQueryDevtoolsProduction />
</React.Suspense>

如代码片段所示,我们用 Suspense 包裹了 ReactQueryDevtoolsProduction 组件,以便它可以被懒加载。你还可以看到我们没有提供任何回退,因为我们正在尝试加载的是 Devtools,我们不需要在模块加载期间添加任何待定状态。

现在,我们不想在渲染我们的组件时自动加载 Devtools。我们想要的只是在我们的应用程序中切换它们的方式。

由于这是一个生产构建,我们不希望在其中有可能会让用户困惑的按钮。因此,一种潜在的处理方式是在我们的 window 对象内部创建一个名为 toggleDevtools 的函数。

这是 React Query 文档建议我们这样做的方式:

  const [showDevtools, setShowDevtools] = React.useState
    (false)
  React.useEffect(() => {
    window.toggleDevtools = () => setShowDevtools
      ((previousState) => !previousState)
  }, [])
  return (
    …
      {showDevtools && (
        <React.Suspense fallback={null}>
          <ReactQueryDevtoolsProduction />
        </React.Suspense>
      )}
    …
  );

在前面的代码片段中,我们做了以下操作:

  1. 创建一个状态变量来保存 Devtools 的当前状态。这个状态变量在用户打开或关闭 Devtools 时更新。

  2. window上运行一个效果,将切换函数分配给我们的window

  3. 在我们的返回中,当我们的showDevtools被切换为开启时,由于我们正在懒加载我们的ReactQueryDevtoolsProduction组件,我们需要用Suspense包裹它,以便能够渲染它。

到目前为止,你已经拥有了在应用程序中使用 React Query 所需的一切。

摘要

在本章中,我们学习了 TanStack Query 以及 React Query 如何融入其中。到现在,你应该能够识别 React Query 使服务器状态管理变得更容易的主要方式,以及它是如何使用查询和突变的。

你学习了QueryClientQueryClientProvider,并了解了它们对于在应用程序中运行 React Query 是基本性的。你还学习了如果你需要,你可以如何自定义自己的QueryClient

最后,你将遇到 React Query Devtools,并学习如何在你的项目中配置它。现在,你也能够在需要做额外调试的特殊场景中将它加载到生产环境中。

第四章使用 React Query 获取数据中,你将了解你的最佳查询助手——useQuery自定义钩子。你将理解它是如何工作的,如何使用它,以及它如何缓存数据。你还将了解你可以触发查询重新获取查询的方式,以及如何构建依赖查询。

第二部分:使用 React Query 管理服务器状态

当处理服务器状态时,许多挑战都与我们从其中读取的方式有关。从缓存到分页,我们将了解 React Query 的自定义钩子useQuery是如何使这项工作变得容易,同时提供令人惊叹的开发者和用户体验。

以及我们读取服务器状态时的挑战,创建、更新和删除它又带来了一组新的挑战。幸运的是,React Query 还有一个名为useMutation的自定义钩子来提供帮助。

在理解了 React Query 的支柱之后,你可能想知道流行的服务器端框架如 Next.js 和 Remix 是否允许你使用 React Query。剧透一下——它们确实可以,你在这里将学习如何使用。

为了总结并确保你晚上能睡得香,你将学习一套你可以用来测试 React Query 的食谱,通过利用 Mock Service Worker 和 React Testing Library 来使用组件和自定义钩子。

这一部分包括以下章节:

  • 第四章, 使用 React Query 获取数据

  • 第五章, 更多数据获取挑战

  • 第六章, 使用 React Query 执行数据突变

  • 第七章, 使用 Next.js 或 Remix 进行服务器端渲染

  • 第八章, 测试 React Query 钩子和组件

第四章:使用 React Query 获取数据

React Query 通过利用其自定义钩子之一useQuery,允许你获取、缓存和处理你的服务器状态。为了你的数据能够被缓存,React Query 有一个称为查询键的概念。结合查询键和一些严格的默认值,React Query 将你的服务器状态管理提升到新的水平。

在本章中,你将了解useQuery钩子,并理解 React Query 是如何让你获取和缓存数据的。在这个过程中,你将了解所有查询中使用的默认值。你还将了解一些可以用来使你的useQuery体验更好的选项。

在熟悉了useQuery之后,你就可以开始在特定场景下使用它来重新获取你的查询。你还可以利用useQuery的一些额外属性来获取相互依赖的查询。

在本章结束时,我们将回顾一个代码文件,以回顾本章所学的内容。

在本章中,我们将涵盖以下主题:

  • useQuery是什么以及它是如何工作的?

  • 使用useQuery重新获取数据

  • 使用useQuery获取依赖查询

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_4

useQuery是什么以及它是如何工作的?

如你在上一章所学,查询是你向异步源发送的请求以获取数据。

在 React Query 文档中,查询也被定义为以下方式:

查询是对异步数据源的声明性依赖,它与一个唯一键相关联。

(tanstack.com/query/v4/docs/guides/queries)

在掌握了这个概念之后,你现在就可以理解 React Query 是如何利用其自定义钩子useQuery来让你订阅查询的。

要使用useQuery自定义钩子,你必须像这样导入它:

import { useQuery } from "@tanstack/react-query";

下面是useQuery的语法:

const values = useQuery({
   queryKey: <insertQueryKey>,
   queryFn: <insertQueryFunction>,
 });

如你所见,useQuery钩子只需要两个参数即可工作:

  • 查询键:用于标识你的查询的唯一键

  • 查询函数:一个返回 Promise 的函数

什么是查询键?

查询键是 React Query 用来标识你的查询的唯一值。它还通过使用查询键,React Query 在QueryCache中缓存你的数据。查询键还允许你手动与查询缓存进行交互。

查询键需要是一个数组,它可以包含一个字符串或一系列其他值,如对象。重要的是,这个查询键数组中的值必须是可序列化的。

在 React Query v4 之前,查询键不一定需要是一个数组。它可以是单个字符串,因为 React Query 会将其内部转换为数组。所以,如果您在网上找到一些不使用数组作为查询键的示例,请不要觉得奇怪。

这里有一些有效的查询键示例:

useQuery({ queryKey: ['users']  })
useQuery({ queryKey: ['users', 10] })
useQuery({ queryKey: ['users', 10, { isVisible: true }] })
useQuery({ queryKey: ['users', page, filters] })

如您所见,只要是一个数组,查询键就是有效的。

作为良好的实践,为了使您的查询键在阅读多个useQuery钩子时更加独特和易于识别,您应该将查询的所有依赖项作为查询键的一部分添加。将其视为与您的useEffect钩子上的依赖项数组相同的模型。这对于阅读目的很有帮助,因为查询键还允许 React Query 在查询的依赖项发生变化时自动重新获取查询。

需要记住的一点是查询键是确定性散列的。这意味着数组内部项的顺序很重要。

这里有一些查询,当它们的查询键被确定性散列时,它们是同一个查询:

useQuery({ queryKey: ['users', 10, { page, filters }] })
useQuery({ queryKey: ['users', 10, { filters, page }] })
useQuery({ queryKey: ['users', 10, { page, random:
  undefined, filters }] })

所有这些示例都是同一个查询——在三个示例中,查询键中数组的顺序保持不变。

现在,您可能想知道这是如何可能的,考虑到对象内部的面页和过滤器每次都会改变位置,在最后一个示例中还有一个名为random的第三个属性。这是真的,但它们仍然在对象内部,而这个对象在查询键数组内部的位置没有改变。此外,random属性是未定义的,所以在散列对象时,它被排除。

现在,让我们看看一些查询,当它们的查询键被确定性散列时,它们不是同一个查询:

useQuery({ queryKey: ['users', 10, undefined, { page,
  filters }] })
useQuery({ queryKey: ['users', { page, filters }, 10] })
useQuery({ queryKey: ['users', 10, { page, filters }] })

所有这些示例都代表不同的查询,因为当查询键是确定性散列时,这些示例最终会变成完全不同的查询。您可能想知道为什么第一个示例与最后一个示例不同。不应该像从{ queryKey: ['users', 10, { page, random: undefined, filters }] })对象中消失的那样,undefined值消失吗?

不,因为在当前场景中,它不在一个对象内部,并且顺序很重要。当它被散列时,这个未定义的值将在散列键内部被转换为一个 null 值。

现在您已经熟悉了查询键,您可以了解更多关于查询函数的内容。

查询函数是什么?

查询函数是一个返回 promise 的函数。这个返回的 promise 将解析并返回数据,或者抛出一个错误。

因为查询函数只需要返回一个 promise,这使得 React Query 变得更加强大,因为查询函数可以支持任何能够执行异步数据获取的客户端。这意味着RESTGraphQL都得到了支持,所以如果您愿意,您可以同时拥有这两种选项。

现在,让我们看看一个使用 GraphQL 的查询函数示例,以及一个使用 REST 的查询函数示例:

GraphQL

import { useQuery } from "@tanstack/react-query";
import { request, gql } from "graphql-request";
const customQuery = gql`
  query {
    posts {
      data {
        id
        title
      }
    }
  }
`;
const fetchGQL = async () => {
  const endpoint = <add_endpoint_here>
  const {
    posts: { data },
  } = await request(endpoint, customQuery);
  return data;
};
 …
useQuery({
queryKey: ["posts"],
queryFn: fetchGQL
});

在前面的代码片段中,我们可以看到一个使用 React Query 和 GraphQL 的例子。这是我们正在做的事情:

  1. 我们首先创建我们的 GraphQL 查询,并将其分配给我们的customQuery变量。

  2. 然后,我们创建fetchGQL函数,它将成为我们的查询函数。

  3. 在我们的useQuery钩子中,我们将相应的查询键传递给钩子,并将我们的fetchGQL函数作为查询函数。

现在,让我们看看如何使用 REST 来完成这个操作:

REST

import axios from "axios";
const fetchData = async () => {
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api`
  );
  return data;
};
…
useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });

在前面的代码片段中,我们可以看到一个使用 React Query 和 REST 的例子。这是我们正在做的事情:

  1. 我们首先创建fetchData函数,它将成为我们的查询函数。

  2. 在我们的useQuery钩子中,我们将相应的查询键传递给钩子,并将我们的fetchData函数作为查询函数。

这些例子让 React Query 更加闪耀,因为只要你有能够执行异步数据获取的客户端,你就可以在查询函数中使用这个客户端。如前所述,为了确保 React Query 能够正确处理你的错误场景,在使用这些客户端时,我们需要检查的一件事是,当你的请求失败时,它们是否会自动抛出错误。如果它们不会抛出错误,你必须自己抛出错误。

这是在使用fetch的查询函数中这样做的方法:

const fetchDataWithFetch = async () => {
  const response = await fetch('https://danieljcafonso.
    builtwithdark.com/react-query-api')
  if (!response.ok) throw new Error('Something failed in
    your request')
  return response.json()
}

在前面的代码片段中,使用fetch执行请求后,我们检查我们的响应是否有效。如果不是,我们抛出一个错误。如果一切正常,我们返回响应数据。

当你持续创建查询和构建查询函数时,最终会想到的一点是,将查询键传递给查询函数会很有帮助。毕竟,如果查询键代表查询的依赖项,那么在查询函数中可能需要它们是有意义的。

你可以这样操作,有两种模式可以这样做:

  • 内联函数

  • 查询函数上下文

内联函数

当你的查询键中不需要传递许多参数到查询函数时,你可以利用这个模式。通过编写内联函数,你可以提供对当前作用域中变量的访问,并将它们传递到查询函数。

这里是这个模式的一个例子:

const fetchData = async (someVariable) => {
 const { data } = await axios.get(
   `https://danieljcafonso.builtwithdark.com/
     react-query-api/${someVariable}`
 );
 return data;
};
…
useQuery({
   queryKey: ["api", someVariable],
   queryFn: () => fetchData(someVariable),
 });

在前面的代码片段中,我们首先创建一个名为fetchData的函数,它将接收一个名为someVariable的参数。这个参数随后被用来补充用于获取数据的 URL。当我们到达useQuery声明时,由于我们需要将someVariable变量用作查询的依赖项,所以我们将其包含在查询键中。最后,在查询函数中,我们创建一个内联函数,该函数将调用fetchData并传递我们的someVariable值。

如你所见,当我们没有很多参数时,这种模式非常出色。现在,考虑一下这种情况:你的查询键最终有 12 个参数,并且它们都在查询函数内部被需要。这并不是一个坏习惯,但它会稍微影响你的代码可读性。为了避免这些情况,你可以求助于 QueryFunctionContext 对象。

QueryFunctionContext

每次调用查询函数时,React Query 会自动将你的查询键作为 QueryFunctionContext 对象传递给查询函数。

这里是使用 QueryFunctionContext 模式的一个例子:

const fetchData = async ({ queryKey }) => {
 const [_queryKeyIdentifier, someVariable] = queryKey;
 const { data } = await axios.get(
   `https://danieljcafonso.builtwithdark.com/
     react-query-api/${someVariable}`
 );
 return data;
};
useQuery({
   queryKey: ["api", someVariable],
   queryFn: fetchData,
 });

在前面的代码片段中,我们首先创建我们的 fetchData 函数。这个函数将接收 QueryFunctionContext 作为参数,因此从这个对象中,我们可以立即解构 queryKey。正如你在 什么是查询键? 部分所知,查询键是一个数组,因此我们传递给函数的参数的顺序对我们传递给查询键的参数顺序很重要。在这个例子中,我们需要 someVariable 变量,它是作为我们数组的第二个元素传递的,因此我们解构我们的数组以获取第二个元素。然后我们使用 someVariable 来补充用于获取数据的 URL。当我们到达 useQuery 声明时,由于我们需要将 someVariable 变量用作查询的依赖项,我们将其包含在查询键中。由于它包含在查询键中,它将自动发送到我们的查询函数。

这种模式减少了创建内联函数的需求,并强制要求将你的查询的所有依赖项添加到查询键中。这种模式可能的一个缺点是,当有这么多参数时,你将不得不记住它们在查询键中添加的顺序,以便在查询函数中使用它们。解决这个问题的方法之一是发送一个包含你查询函数中所需的所有参数的对象。这样,你就无需记住数组元素的顺序。

这就是你可以这样做的方式:

useQuery({
   queryKey: [{queryIdentifier: "api", someVariable}],
   queryFn: fetchData,
 });

通过将一个对象作为你的查询键传递,该对象将被作为 QueryFunctionContext 对象发送到你的查询函数。

然后,在你的函数中,你只需要这样做:

const fetchData = async ({ queryKey }) => {
  const { someVariable } = queryKey[0];
…
};

在前面的代码片段中,我们从 QueryFunctionContext 对象中解构我们的 queryKey。然后,由于我们的对象将是查询键的第一个位置,我们可以从那里解构我们需要的值。

现在你已经理解了每个 useQuery 钩子所需的两个选项,我们可以开始查看它返回的内容。

useQuery 返回什么?

当使用 useQuery 钩子时,它返回几个值。要访问这些值,你只需将钩子的返回值分配给一个变量或从钩子的返回值中解构值即可。

你可以这样做:

const values = useQuery(...);
const { data, error, status, fetchStatus }= useQuery(...);

在这个代码片段中,我们可以看到访问 useQuery 钩子返回值的两种不同方式。

在本节中,我们将回顾 useQuery 钩子的以下返回值:

  • data

  • error

  • status

  • fetchStatus

data

这个变量是查询函数返回的最后成功解析的数据。

这就是你可以使用data变量的方式:

const App = () => {
  const { data } = useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });
  return (
    <div>
       {data ? data.hello : null}
    </div>
  );
};

在这个代码片段中,我们做了以下操作:

  1. 我们从useQuery钩子中解构data变量。

  2. 在我们的返回中,我们检查是否已经从我们的查询中获取了数据。当我们这样做时,我们将渲染它。

当查询最初执行时,这些数据将是未定义的。一旦它执行完成并且查询函数成功解析了你的数据,我们就能访问这些数据。如果由于某种原因,我们的查询函数的 promise 被拒绝,那么我们可以使用下一个变量:error

error

error变量让你能够访问查询函数失败后返回的错误对象。

这就是你可以使用error变量的方式:

const App = () => {
  const { error } = useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });
  return (
    <div>
       {error ? error.message : null}
    </div>
  );
};

在前面的代码片段中,我们做了以下操作:

  1. 我们从useQuery钩子中解构error变量。

  2. 在我们的返回中,我们检查是否有任何错误。如果有,我们渲染error信息。

当查询最初执行时,error值将是 null。如果由于某种原因,查询函数拒绝并抛出错误,那么这个错误将被分配给我们的error变量。

dataerror的示例中,我们都检查了它们是否已定义,这样我们就可以让我们的应用程序用户知道我们查询的当前状态。为了使这更容易,并帮助你为应用程序创建更好的用户体验,添加了status变量。

status

在执行查询时,查询可以经过几个状态。这些状态帮助你向用户提供更多的反馈。为了让你知道查询的当前状态,创建了status变量。

这里是status变量可能具有的状态:

  • loading:没有查询尝试完成,并且还没有缓存的数据。

  • error:在执行查询时出现了错误。每当这是状态时,error属性将接收查询函数返回的错误。

  • success:你的查询成功并且返回了数据。每当这是状态时,data属性将接收查询函数的成功数据。

这就是你可以使用status变量的方式:

const App = () => {
  const { status, error, data } = useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });
  if(status === "loading") {
    return <div>Loading...</div>
  }
  if(status === "error") {
    return <div>There was an unexpected error:
      {error.message}</div>
  }
  return (
    <div>
       {data.hello}
    </div>
  );
};

在前面的代码片段中,我们正在利用status变量为我们的用户提供更好的用户体验。这是我们在做的事情:

  1. 我们首先从useQuery钩子中解构status变量。

  2. 我们检查status是否为加载状态。这意味着我们还没有任何数据,并且我们的查询已经完成。如果情况如此,我们将渲染一个加载指示器。

  3. 如果我们的status不是加载状态,我们检查查询过程中是否出现了任何错误。如果我们的status等于error,那么我们需要解构我们的error变量并显示错误信息。

  4. 最后,如果我们的status也不是错误状态,那么我们可以安全地假设我们的status等于成功;因此,我们应该有查询函数返回的数据的data变量,并且我们可以将其显示给用户。

现在,你已经知道了如何使用status变量。为了方便,React Query 还引入了一些布尔变体来帮助我们识别每个状态。它们如下所示:

  • isLoading:你的status变量处于加载状态

  • isError:你的status变量处于错误状态

  • isSuccess:你的status变量处于成功状态

让我们重写我们之前的代码片段,利用我们的status布尔变体:

const App = () => {
  const {  isLoading, isError, error, data } = useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });
  if(isLoading) {
    return <div>Loading...</div>
  }
  if(isError) {
    return <div>There was an unexpected error:
      {error.message}</div>
  }
  return (
    <div>
       {data.hello}
    </div>
  );
};

如你所见,代码是相似的。我们只需要在我们的解构中将status变量替换为isLoadingisError,然后在相应的状态检查中使用isLoadingisError变量。

现在,status变量为你提供了关于你的查询数据的信息。然而,这并不是 React Query 拥有的唯一状态变量。在下一节中,你将介绍fetchStatus

fetchStatus

在 React Query v3 中,他们发现当处理用户可能离线的情况时存在一个问题。如果用户触发了查询,但在请求过程中由于某种原因失去了连接,status变量将保持在加载状态,直到用户重新获得连接并且查询自动重试。

为了处理这类问题,在 React Query v4 中,他们引入了一个新的属性,称为networkMode。这个属性可以有三种状态,但默认情况下将使用在线状态。好事是这种模式允许你使用fetchStatus变量。

fetchStatus变量为你提供了关于你的查询函数的信息。

这里是这个变量可能具有的状态:

  • fetching:你的查询函数目前正在执行。这意味着它目前正在获取数据。

  • paused:你的查询想要获取数据,但由于失去了连接,它现在已经停止执行。这意味着它目前处于暂停状态。

  • idle:查询目前没有任何操作。这意味着它目前处于空闲状态。

现在,让我们学习如何使用fetchStatus变量:

const App = () => {
  const {  fetchStatus, data } = useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });
  if(fetchStatus === "paused") {
    return <div>Waiting for your connection to return…
      </div>
  }
  if(fetchStatus === "fetching") {
    return <div>Fetching…</div>
  }
  return (
    <div>
       {data.hello}
    </div>
  );
};

在前面的代码片段中,我们正在利用fetchStatus变量为我们的用户提供更好的用户体验。我们正在做的是:

  1. 我们首先从useQuery钩子的返回值中解构出fetchStatus变量。

  2. 我们接着检查我们的fetchStatus当前状态是否为暂停。如果是true,那么现在没有网络连接,因此我们让我们的用户知道。

  3. 如果之前的If 检查false,那么我们可以验证我们的fetchStatus当前状态是否为获取。如果之前的If 检查true,那么现在查询函数正在运行,因此我们让我们的用户知道。

  4. 如果我们不是在获取数据,那么我们可以假设我们的查询函数的fetchStatus是空闲的;因此,它已经完成了获取,所以我们应该有返回的数据。

现在,你已经知道了如何使用fetchStatus变量。就像status变量一样,React Query 也引入了一些布尔变体来帮助我们识别这两种状态。它们如下所示:

  • isFetching:您的 fetchStatus 变量处于获取状态

  • isPaused:您的 fetchStatus 变量处于暂停状态

让我们利用我们的 fetchStatus 布尔变体重写之前的片段:

const App = () => {
  const { isFetching, isPaused, data } = useQuery({
    queryKey" [""pi"],
    queryFn: fetchData,
  });
  if(isPaused) {
    return <div>Waiting for your connection to return...
      </div>
  }
  if(isFetching) {
    return <div>Fetching...</div>
  }
  return (
    <div>
       {data.hello}
    </div>
  );
};

如您从片段中看到的,代码相当相似。我们只需将我们的 fetchStatus 变量替换为解构中的 isFetchingisPaused,然后在相应的 fetchStatus 检查中使用这些 isFetchingisPaused 变量。

既然我们已经了解了 useQuery 钩子返回的值,让我们看看我们如何使用一些选项来定制相同的钩子。

常用选项解释

当使用 useQuery 钩子时,可以传递比查询键和查询函数更多的选项。这些选项帮助您创建更好的开发者体验,以及更好的用户体验。

在本节中,我们将探讨一些更常见且非常重要的选项,您需要了解。

下面是我们要介绍的一些选项:

  • staleTime

  • cacheTime

  • retry

  • retryDelay

  • 启用

  • onSuccess

  • onError

staleTime

staleTime 选项是查询数据不再被认为是 新鲜 的毫秒数。当设置的时间过去后,查询会被称为 过时

当查询处于 新鲜 状态时,它将从缓存中拉取,而不会触发更新缓存的新请求。当查询被标记为 过时 时,数据仍然会从缓存中拉取,但可以触发查询的自动重新获取。

默认情况下,所有查询都使用设置为 0staleTime。这意味着所有缓存数据默认都会被认为是 过时 的。

这是我们如何配置 staleTime 的方法:

useQuery({
  staleTime: 60000,
});

在这个片段中,我们定义这个钩子的查询数据在一分钟内被认为是 新鲜 的。

cacheTime

cacheTime 选项是缓存中不活跃数据在内存中保持的时间(以毫秒为单位)。一旦这个时间过去,数据将被垃圾回收。

默认情况下,当没有 useQuery 钩子的活动实例时,查询会被标记为不活跃。当这种情况发生时,这个查询数据将保留在缓存中 5 分钟。在这 5 分钟后,这些数据将被垃圾回收。

这就是如何使用 cacheTime 选项:

useQuery({
  cacheTime: 60000,
});

在片段中,我们定义了在查询不活跃 1 分钟后,数据将被垃圾回收。

重试

retry 选项是一个值,表示查询失败时是否会重试。当 true 时,它会重试直到成功。当 false 时,它不会重试。

这个属性也可以是一个数字。当它是一个数字时,查询将重试指定次数。

默认情况下,所有失败的查询都会重试三次。

这就是如何使用 retry 选项:

useQuery({
  retry: false,
});

在这个片段中,我们将 retry 选项设置为 false。这意味着当查询失败时,这个钩子不会尝试重新获取数据。

我们也可以这样配置 retry 选项:

useQuery({
  retry: 1,
});

在这个片段中,我们将数字 1 设置为 retry 选项。这意味着如果这个钩子无法获取查询,它将只重试请求一次。

retryDelay

retryDelay 选项是在下一次重试尝试之前应用的延迟时间(以毫秒为单位)。

默认情况下,React Query 使用指数退避延迟算法来定义重试之间的时间间隔。

这是使用 retryDelay 选项的方法:

useQuery({
  retryDelay: (attempt) => attempt * 2000,
});

在这个片段中,我们定义了一个线性退避函数作为我们的 retryDelay 选项。每次重试时,这个函数都会接收到尝试次数并将其乘以 2000。这意味着每次重试之间的时间间隔将增加 2 秒。

enabled

enabled 选项是一个布尔值,表示您的查询何时可以运行或不能运行。

默认情况下,此值是 true,因此所有查询都被启用。

这是使用 enabled 选项的方法:

useQuery({
  enabled: arrayVariable.length > 0
});

在这个片段中,我们将表达式评估的返回值分配给 enabled 选项。这意味着只要 arrayVariable 的长度大于 0,这个查询就会执行。

onSuccess

onSuccess 选项是一个函数,当您的查询在获取过程中成功时将被触发。

这是使用 onSuccess 选项的方法:

useQuery({
  onSuccess: (data) => console.log("query was successful",
    data),
});

在这个片段中,我们将一个箭头函数传递给我们的 onSuccess 选项。当我们的查询成功获取数据时,这个函数将使用我们的 data 作为参数被调用。然后我们使用这个 data 来在我们的 console 中进行日志记录。

onError

当您的查询在获取过程中失败时,onError 选项是一个将被触发的函数。

这是使用 onError 选项的方法:

useQuery({
  onError: (error) => console.log("query was unsuccessful",
    error.message),
});

在这个片段中,我们将一个箭头函数传递给我们的 onError 选项。当查询失败时,这个函数将使用 thrown 错误作为参数被调用。然后我们在我们的 console 中记录错误。

如您所见,useQuery 钩子支持很多选项,而之前展示的只是冰山一角。在接下来的章节中,您将了解到更多,所以请做好准备!

您现在熟悉了 useQuery 钩子,应该能够使用它来开始获取您的服务器状态数据。现在,让我们看看一些模式和方式,我们可以使用这个钩子来处理一些常见的服务器状态挑战。

使用 useQuery 重新获取数据

重新获取数据是管理我们的服务器状态的一个重要部分。有时,您需要更新数据,因为数据已经过时,或者是因为您已经有一段时间没有与页面交互了。

无论手动还是自动,React Query 都支持并允许您重新获取数据。

在本节中,我们将了解它是如何工作的,以及您可以利用哪些自动和手动方式来重新获取数据。

自动重新获取

React Query 内置了一些选项,以使您的生命更轻松并保持服务器状态新鲜。为此,它会在某些情况下自动处理数据重新获取。

让我们看看允许 React Query 自动执行数据重新获取的事物。

查询键

查询键用于标识您的查询。

在之前讨论查询键时,我多次提到我们应该将所有查询函数的依赖项作为查询键的一部分包括在内。为什么我会这么说?

因为当这些依赖项中的任何一个发生变化时,你的查询键也会发生变化,当你的查询键发生变化时,你的查询将自动重新获取。

让我们看看以下示例:

const [someVariable, setSomeVariable] = useState(0)
useQuery({
    queryKey: ["api", someVariable],
    queryFn: fetchData,
  });
return <button onClick={() => setSomeVariable
  (someVariable + 1)}> Click me </button>

在前面的代码片段中,我们定义了一个useQuery钩子,其中someVariable是其查询键的一部分。这个查询将像往常一样在初始渲染时获取,但当我们点击我们的按钮时,someVariable的值将改变。查询键也会改变,这将触发查询重新获取以获取你的新数据。

重新获取选项

在“常用选项解释”部分中,我没有分享的一些选项。这是因为它们默认启用,通常最好保留它们,除非它们不适合你的用例。

这里是useQuery默认启用的与数据重新获取相关的选项:

  • refetchOnWindowFocus:每当你的当前窗口获得焦点时,此选项会触发重新获取。例如,当你返回到你的应用程序并更改标签页时,React Query 将触发数据的重新获取。

  • refetchOnMount:每当你的钩子挂载时,此选项会触发重新获取。例如,当一个新的使用你的钩子的组件挂载时,React Query 将触发数据的重新获取。

  • refetchOnReconnect:每当你的互联网连接丢失时,此选项将触发重新获取。

有一点很重要,即默认情况下,这些选项只会重新获取你的数据,如果你的数据被标记为过时。即使数据已过时,这种数据重新获取也可以配置,因为所有这些选项(除布尔值外)也支持接收一个值为always的字符串。当这些选项的值为always时,它将始终重新触发重新获取,即使数据没有过时。

这是如何配置它们的:

useQuery({
    refetchOnMount: "always",
    refetchOnReconnect: true,
    refetchOnWindowFocus: false
  });

在前面的代码片段中,我们正在做以下操作:

  • 对于refetchOnMount选项,我们总是希望我们的钩子在任何使用它的组件挂载时重新获取我们的数据,即使缓存的数据没有过时

  • 对于refetchOnReconnect,我们希望我们的钩子在我们离线后重新获得连接时重新获取我们的数据,但只有当我们的数据已过时

  • 对于refetchOnWindowFocus,我们绝不想在窗口聚焦时让我们的钩子重新获取数据

现在,你可能想到的一个问题是是否有任何方法可以强制我们的钩子每隔几秒钟重新获取我们的数据,即使数据没有过时。好吧,即使你没有想过,React Query 也允许你这样做。

React Query 添加了另一个与重新获取相关的选项,称为refetchInterval。此选项允许你指定查询重新获取数据的频率(以毫秒为单位)。

这是如何使用它的:

useQuery({
    refetchInterval: 2000,
    refetchIntervalInBackground: true
  });

在这个片段中,我们配置我们的钩子以每 2 秒自动重新获取一次。我们还添加了一个名为refetchIntervalInBackground的选项,其值为true。此选项将允许您的查询即使在窗口或标签页处于后台时也能继续重新获取。

这总结了自动重新获取。现在,让我们看看我们如何在代码中触发手动重新获取。

手动重新获取

有两种手动触发查询重新获取的方式。您可以使用QueryClient或从钩子中获取refetch函数。

使用 QueryClient

如您可能从上一章回忆起的,QueryClient允许您在开发者和查询缓存之间建立接口。这允许您利用QueryClient在需要时强制数据重新获取。

这就是您可以使用QueryClient触发数据重新获取的方式:

const queryClient = useQueryClient();
queryClient.refetchQueries({ queryKey: ["api"] })

在前面的片段中,我们正在执行以下操作:

  • 使用useQueryClient钩子来获取对QueryClient的访问权限。

  • 使用QueryClient,我们调用它公开的一个函数,称为refetchQueries。此函数允许您触发与给定查询键匹配的所有查询的重新获取。在这个片段中,我们正在触发具有["api"]查询键的所有查询的请求。

使用重新获取函数

每个useQuery钩子都公开一个refetch函数以方便使用。此函数将允许您仅触发该查询的重新获取。

这就是您可以这样做的:

const { refetch } = useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });
refetch()

在这个片段中,我们从useQuery钩子中解构了refetch函数。然后,我们可以在任何时候调用该函数,以强制该查询重新获取。

现在您已经知道了 React Query 如何使您能够手动和自动重新获取数据,让我们看看我们如何创建依赖于其他查询的查询。

使用useQuery获取依赖查询

有时,在开发过程中,我们需要使用一个查询返回的值,这些值可以在另一个查询中使用,或者使查询执行依赖于之前的查询。当这种情况发生时,我们需要有一个称为依赖查询的东西。

React Query 允许您通过enabled选项使一个查询依赖于其他查询。

这就是您可以这样做的:

const App = () => {
  const { data: firstQueryData } = useQuery({
    queryKey: ["api"],
    queryFn: fetchData,
  });
  const canThisDependentQueryFetch = firstQueryData?.hello
    !== undefined;
  const { data: dependentData } = useQuery({
    queryKey: ["dependentApi", firstQueryData?.hello],
    queryFn: fetchDependentData,
    enabled: canThisDependentQueryFetch,
  });
…

在前面的片段中,我们正在执行以下操作:

  1. 我们正在创建一个查询,其查询键为["api"],查询函数为fetchData函数。

  2. 接下来,我们创建一个名为canThisDependentQueryFetch的布尔变量,该变量将检查我们的上一个查询是否有我们所需的数据。这个布尔变量将帮助我们决定我们的下一个查询是否可以获取。

  3. 然后,我们创建第二个查询,其查询键为["dependentAPI", firstQueryData?.hello],查询函数为fetchDependentData函数,以及我们的canThisDependentQueryFetch作为enabled选项的Boolean变量。

当之前的查询完成数据获取后,canThisDependentQueryFetch布尔值将被设置为true,并启用此依赖查询的运行。

如您所见,您只需要enabled选项就可以使一个查询依赖于另一个查询。现在,在结束这一章之前,让我们将我们所学到的所有知识付诸实践。

将所有内容付诸实践

到目前为止,你应该能够开始处理一些使用useQuery钩子的数据获取用例。

在本节中,我们将查看一个包含三个组件的文件,这些组件分别称为ComponentAComponentBComponentC,它们正在执行一些数据获取操作。我们将使用这个文件来回顾我们已经学到的概念,并查看我们是否完全理解了useQuery的工作方式。

让我们从文件的开始部分开始:

import { useQuery, useQueryClient } from "@tanstack/react-query";
const fetchData = async ({ queryKey }) => {
  const { apiName } = queryKey[0];
  const response = await fetch(
    `https://danieljcafonso.builtwithdark.com/${apiName}`
  );
  if (!response.ok) throw new Error("Something failed in
    your request");
  return response.json();
};
const apiA = "react-query-api";
const apiB = "react-query-api-two";

在前面的代码片段中,我们正在做以下操作:

  1. 我们从 React Query 包中导入我们的useQueryuseQueryClient自定义钩子,以便在定义在接下来的几个代码片段中的组件中使用。

  2. 我们创建了一个fetchData函数,该函数将接收我们的QueryFunctionContext。然后我们从其中解构出queryKey。在这个函数内部,我们执行以下操作:

    1. 在这些示例中,我们将使用一个对象作为查询键,这样我们就可以知道数组的第一个位置将包含我们的查询键属性,因此我们从其中解构出apiName

    2. 我们使用fetch触发对 URL 的GET请求,并使用apiName来帮助定义路由。

    3. 因为我们使用的是fetch而不是axios,所以我们需要手动处理请求失败的情况。如果我们的响应不是 OK,那么我们需要抛出一个错误,以便useQuery能够处理错误场景。

    4. 如果我们的响应是有效的,那么我们可以返回我们的响应数据。

  3. 然后,我们创建了两个 API 常量值,分别称为apiAapiB,它们定义了组件将使用的路由。

现在,让我们继续我们的文件,并查看我们的第一个组件,称为ComponentA

const ComponentA = () => {
  const { data, error, isLoading, isError, isFetching } =
    useQuery({
    queryKey: [{ queryIdentifier: "api", apiName: apiA }],
    queryFn: fetchData,
    retry: 1,
  });
  if (isLoading) return <div> Loading data... </div>;
  if (isError)
    return (
      <div> Something went wrong... Here is the error:
        {error.message}</div>
    );
  return (
    <div>
      <p>{isFetching ? "Fetching Component A..." :
        data.hello} </p>
      <ComponentB/>
    </div>
  );
};

让我们回顾一下ComponentA

  1. 我们首先通过使用useQuery钩子来创建我们的查询:

    1. 这个查询使用一个对象作为查询键。这个对象有api作为queryIdentifier属性和apiA作为apiName属性。

    2. 这个查询的查询函数是fetchData函数。

    3. 通过使用retry选项,我们还指定如果这个查询在获取数据时失败,那么钩子将只重试请求一次。

    4. 我们还从钩子中解构出dataisLoadingisErrorisFetching

  2. 如果没有查询尝试完成,并且仍然没有缓存的数据,我们希望向用户显示我们正在加载数据。我们使用isLoadingIf检查来实现这一点。

  3. 如果有错误,我们希望显示它。我们使用isError来检查是否有任何错误。如果有,我们渲染那个错误。

  4. 如果我们的查询没有加载或出现错误,那么我们可以假设它是成功的。然后我们渲染一个包含以下内容的div

    • 一个p tag将检查我们的钩子isFetching。如果正在获取,它将显示Fetching Component A。如果不正在获取,它将显示获取到的数据。

    • 我们的ComponentB

现在,让我们看看ComponentB

const ComponentB = () => {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", apiName: apiB }],
    queryFn: fetchData,
    onSuccess: (data) => console.log("Component B fetched
      data", data),
  });
  return (
    <div>
      <span>{data?.hello}</span>
      <ComponentC parentData={data} />
    </div>
  );
};

这就是我们ComponentB中正在做的事情:

  1. 我们首先通过使用useQuery钩子创建我们的查询:

    1. 此查询通过一个对象作为查询键进行标识。此对象具有api作为queryIdentifier属性和apiB作为apiName属性。

    2. 此查询具有fetchData函数作为查询函数。

    3. 我们使用onSuccess选项并传递一个函数,该函数将接收我们的data并在我们的console上记录它,以及指示此组件已获取数据。

    4. 我们还从钩子中解构data

  2. 然后我们返回一个div以进行渲染,如下所示:

    • 我们从获取的数据中获取的hello属性。你可能看到的一件事是我们使用了?.运算符。我们利用可选链来确保没有错误,并且只有当我们的数据定义时,我们才渲染hello属性。

    • 我们的ComponentC。此组件将接收我们的ComponentB数据作为其parentData属性。

让我们通过查看ComponentC来总结我们的文件审查:

const ComponentC = ({ parentData }) => {
  const { data, isFetching } = useQuery({
    queryKey: [{ queryIdentifier: "api", apiName: apiA }],
    queryFn: fetchData,
    enabled: parentData !== undefined,
  });
  const queryClient = useQueryClient();
  return (
    <div>
      <p>{isFetching ? "Fetching Component C..." :
        data.hello} </p>
      <button
        onClick={() =>
          queryClient.refetchQueries({
            queryKey: [{ queryIdentifier: "api",
              apiName: apiA }],
          })
        }
      >
        Refetch Parent Data
      </button>
    </div>
  );
};
export default ComponentA;

因此,这是ComponentC中正在发生的事情:

  1. 我们首先通过使用useQuery钩子创建我们的查询:

    1. 此查询通过一个对象作为查询键进行标识。此对象具有api作为queryIdentifier属性和apiA作为apiName属性。

    2. 此查询具有fetchData函数作为查询函数。

    3. 我们使用enabled选项使此查询依赖于parentData;因此,此查询只有在ComponentB中的查询完成并解析数据后才会运行。

    4. 我们从钩子中解构dataisFetching

  2. 我们使用useQueryClient钩子来获取对QueryClient的访问权限。

  3. 最后,我们返回一个将渲染以下内容的div

    • 一个p标签,将检查我们的钩子isFetching。如果正在获取,则显示Fetching Component C。如果不,则显示获取的数据。

    • 一个按钮,当点击时,将使用queryClient重新获取查询键具有api作为queryIdentifier属性和apiA作为apiName属性的查询。这意味着在此按钮点击时,ComponentAComponentC中的useQuery都将重新获取一些数据。

此外,在前面的代码片段中,我们默认导出我们的ComponentA,因此它是此文件的入口点。

现在我们已经看到了代码文件,让我们回顾钩子的生命周期并了解后台发生了什么:

  • ComponentA渲染时,以下情况会发生:

    • 一个具有[{ queryIdentifier: "api", apiName: apiA }]查询键的useQuery实例挂载:

      • 由于这是第一次挂载,没有缓存也没有之前的请求,因此我们的查询将开始获取我们的数据,其status将为加载状态。此外,我们的查询函数将作为QueryFunctionContext的一部分接收我们的查询键。

      • 当我们的数据获取成功时,数据将根据[{ queryIdentifier: "api", apiName: apiA }]查询键进行缓存。

      • 由于我们假设默认的staleTime,其值为0,钩子将标记其数据为过时。

  • ComponentA渲染ComponentB时,以下情况会发生:

    • 具有查询键[{ queryIdentifier: "api", apiName: apiB }]useQuery实例挂载:

      • 由于这是第一次挂载,没有缓存也没有之前的请求,因此我们的查询将开始获取数据,其状态将是加载中。此外,我们的查询函数将作为QueryFunctionContext的一部分接收我们的查询键。

      • 当我们的数据获取成功时,数据将根据[{ queryIdentifier: "api", apiName: apiB }]查询键进行缓存,并且钩子将调用onSuccess函数。

      • 由于我们假设默认的staleTime,即0,钩子将标记其数据为过时。

  • ComponentB渲染ComponentC时,以下情况发生:

    • 具有查询键[{ queryIdentifier: "api", apiName: apiA }]useQuery实例挂载:

      • 由于此钩子与ComponentA中的钩子具有相同的查询键,钩子下已经缓存了数据,因此数据可以立即访问。

      • 由于此查询在之前的获取后标记为过时,此钩子需要重新获取它,但它需要等待查询首先启用,因为此查询依赖于我们首先拥有ComponentB的数据。

      • 一旦启用,查询将触发重新获取。这使得ComponentAComponentC上的isFetching都变为true

      • 一旦获取请求成功,数据将根据[{ queryIdentifier: "api", apiName: apiA }]查询键进行缓存,并且查询再次标记为过时。

  • 现在,考虑到它是父组件,让我们设想一个场景,其中ComponentA卸载:

    • 由于不再有任何具有[{ queryIdentifier: "api", apiName: apiA }]查询键的活动查询实例,默认的缓存超时设置为 5 分钟。

    • 一旦过去 5 分钟,此查询下的数据将被删除并回收。

    • 由于不再有任何具有[{ queryIdentifier: "api", apiName: apiB }]查询键的活动查询实例,默认的缓存超时设置为 5 分钟。

    • 一旦过去 5 分钟,此查询下的数据将被删除并回收。

如果你能够跟踪此前的过程以及查询在它们使用过程中的生命周期,那么恭喜你:你理解了useQuery是如何工作的!

摘要

在本章中,我们学习了useQuery自定义钩子以及它是如何通过使用其必需的选项(称为查询键和查询函数)来获取和缓存数据的。你学习了如何定义查询键以及你的查询函数如何允许你使用任何数据获取客户端,如 GraphQL 或 REST,只要它返回一个承诺或抛出一个错误。

你还了解了一些useQuery钩子返回的内容,例如查询的dataerror。为了构建更好的用户体验,你还介绍了statusfetchStatus

为了让你自定义开发者体验并将其提升到下一个层次,你了解了一些常用的选项,你可以使用这些选项来自定义你的useQuery钩子,使其按你的意愿运行。为了你的方便,以下是一些需要注意的编译默认值:

  • staleTime: 0

  • cacheTime: 5 * 60 * 1,000 (5 minutes)

  • retry: 3

  • retryDelay: 指数退避延迟算法

  • enabled: True

在结束之前,你了解了一些处理服务器状态挑战(如重新获取和依赖查询)的模式。

最后,你将所学的一切付诸实践,并回顾了一个示例,展示了如何利用所有这些知识,以及当你这样做时useQuery钩子是如何在内部工作的。

第五章《更多数据获取挑战》中,你将继续学习如何使用useQuery钩子来解决一些更常见的服务器状态挑战,例如数据预取、分页请求和无限制查询。你还将使用开发者工具来帮助你调试查询。

第五章:更多数据获取挑战

到现在为止,你必须熟悉 React Query 如何通过 useQuery 帮助你获取数据。你甚至学习了如何处理服务器状态带来的某些常见挑战。

在本章中,你将学习如何处理一些更多的服务器状态挑战。你将了解你如何执行并行查询,在这个过程中,你将了解一个使 useQuery 钩子更容易使用的变体,称为 useQueries

你将再次利用 QueryClient 来处理数据预取、查询无效化和查询取消。你甚至将学习如何通过使用一些过滤器来自定义你用来做这些事情的方法。

useQuery 以及甚至另一个名为 useInfiniteQuery 的变体。

到本章结束时,你将再次使用 Devtools 来查看你的查询,并增强对其的调试。

在本章中,我们将涵盖以下主题:

  • 构建并行查询

  • 利用 QueryClient

  • 创建分页查询

  • 创建无限查询

  • 使用 Devtools 调试你的查询

技术要求

本章的所有代码示例都可以在 GitHub 上找到,链接为 github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_5

构建并行查询

我们经常发现需要使用的一个典型模式是并行查询。并行查询是指同时执行的查询,以避免有顺序的网络请求,通常称为网络瀑布。

并行查询可以帮助你通过同时发送所有请求来避免网络瀑布。

React Query 允许我们以两种方式执行并行查询:

  • 手动

  • 动态地

手动并行查询

如果我现在要求你正确地执行并行查询,这可能是你可能会这样做的方式。它只涉及并排编写任意数量的 useQuery 钩子。

当你需要执行固定数量的并行查询时,这种模式非常出色。这意味着你将执行查询的数量始终相同,不会改变。

这就是你可以按照这种方法编写并行查询的方式:

const ExampleOne = () => {
  const { data: queryOneData  } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "userOne" }],
    queryFn: fetchData,
  });
  const { data: queryTwoData } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "userTwo" }],
    queryFn: fetchData,
  });
  const { data: queryThreeData } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "userThree" }],
    queryFn: fetchData,
  });
  return (
    <div>
      <p>{queryOneData?.hello}</p>
      <p>{queryTwoData?.hello}</p>
      <p>{queryThreeData?.hello}</p>
    </div>
  );
};

在前面的代码片段中,我们通过向所有这些查询添加不同的查询键来创建三个不同的查询。这些查询将并行获取,一旦查询函数解决,我们将能够访问它们的数据。然后我们使用这些数据在 p 标签内渲染它们的 hello 属性。

动态并行查询

虽然手动并行查询适用于大多数场景,但如果你的查询数量变化,你将无法在不违反钩子规则的情况下使用它。为了处理这个问题,React Query 创建了一个名为 useQueries 的自定义钩子。

useQueries 允许你动态地调用你想要的任意数量的查询。以下是它的语法:

const queryResults = useQueries({
  queries: [
    { queryKey: ["api", "queryOne"], queryFn: fetchData },
    { queryKey: ["api", "queryTwo"], queryFn: fetchData }
  ]
})

如前所述的代码片段所示,useQueries钩子在它的queries属性中接收一个查询数组。这些查询甚至可以接收选项,所以你应该有这样的心理模型:这些查询可以像useQuery钩子一样进行定制。

useQueries钩子将返回一个包含所有查询结果的数组。

既然你已经了解了useQueries的工作原理,让我们在下面的代码片段中将其付诸实践:

const usernameList = ["userOne", "userTwo", "userThree"];
const ExampleTwo = () => {
  const multipleQueries = useQueries({
    queries: usernameList.map((username) => {
      return {
        queryKey: [{ queryIdentifier: "api", username }],
        queryFn: fetchData,
      };
    }),
  });
  return (
    <div>
      {multipleQueries.map(({ data, isFetching }) => (
        <p>{isFetching ? "Fetching data..." : data.hello}
          </p>
      ))}
    </div>
  );
};

在前面的代码片段中,我们做了以下操作:

  1. 我们创建一个usernameList字符串数组来帮助我们创建一些动态查询。

  2. 在我们的useQueries钩子内部,对于usernameList中的每个实例,我们创建一个相应的查询,包括其查询键和查询函数。

  3. 我们使用useQueries钩子的结果;对于它内部的每个项目,我们利用isFetching向用户显示我们正在获取数据。如果它没有获取数据,那么我们假设我们已经完成了我们的请求,并显示获取到的数据。

现在你已经知道如何利用useQueryuseQueries来执行并行查询,让我们看看你如何利用QueryClient来解决一些更多的服务器状态挑战。

利用 QueryClient

正如你所知,QueryClient允许你与你的缓存进行交互。

在上一章中,我们看到了如何利用QueryClient来触发查询的重新获取。我们还没有看到的是QueryClient可以用于更多的事情。

要在你的组件中使用QueryClient,你可以利用useQueryClient钩子来访问它。然后,你所要做的就是调用你需要的那个方法。

在本节中,我们将看到如何使用QueryClient来解决更多服务器状态挑战,例如以下内容:

  • 查询无效化

  • 预取

  • 查询取消

在我们开始查询无效化之前,有一件事需要注意,那就是其中一些方法,即我们即将看到的方法,可以接收某些查询过滤器来帮助你匹配正确的查询。

在上一章中,我们看到了查询重新获取的以下示例:

queryClient.refetchQueries({ queryKey: ["api"] })

前面的代码片段是一个示例,说明我们可以在refetchQueries方法中提供一个过滤器。在这种情况下,我们正在尝试重新获取所有匹配或以查询键["api"]开头的查询。

现在,你可以使用除了查询键之外的更多过滤器。在QueryClient方法中使用的过滤器,通常称为QueryFilters,支持以下类型的过滤:

  • 查询键

  • 查询类型

  • 查询是否过时或新鲜

  • fetchStatus

  • 一个谓词函数

这里有一些使用QueryFilters的示例。

在下面的示例中,我们使用type过滤器与active值一起重新获取所有当前处于活动状态的查询:

queryClient.refetchQueries({ type: "active" })

在下面的示例中,我们使用stale过滤器与true值一起重新获取所有staleTime已过期的查询,现在被认为是过时的:

queryClient.refetchQueries({ stale: true })

在以下示例中,我们使用fetchStatus过滤器与idle值一起重新获取所有当前未获取任何内容的查询:

queryClient.refetchQueries({ fetchStatus: "idle"})

在以下示例中,我们使用predicate属性并向其传递一个匿名函数。此函数将接收正在验证的查询并访问其当前状态;如果此状态是错误,则函数将返回true。这意味着所有当前状态为错误的查询都将重新获取。

queryClient.refetchQueries({
            predicate: (query) => query.state.status ===
              "error",
})

现在,您不需要只传递一个过滤器。您可以发送以下组合的过滤器:

queryClient.refetchQueries({ queryKey: ["api"], stale: true
  })

在前面的示例中,我们重新获取了所有以["api"]开头的过时查询。

如果您不想传递任何过滤器并希望方法应用于所有查询,您可以选择不传递任何过滤器,如下所示:

queryClient.refetchQueries()

此示例将重新获取所有查询。

您现在熟悉了QueryFilters,并可以看到其中涉及的一些服务器状态挑战。让我们从查询无效化开始。

查询无效化

有时,独立于您配置的staleTime,您的数据可能会变得过时。为什么,您可能会问?好吧,有时可能是因为您执行的突变;有时可能是因为其他用户在某个地方与您的服务器状态进行了交互。

当这种情况发生时,您可以使用您的QueryClient invalidateQueries方法将查询标记为过时。

这里是invalidateQueries方法的语法:

queryClient.invalidateQueries({ queryKey: ["api"] })

通过调用invalidateQueries,所有匹配或以["api"]开头的查询都将被标记为stale,如果已配置,则覆盖其staleTime。如果您的查询是活动状态,因为useQuery钩子渲染正在使用它,那么 React Query 将负责重新获取该查询。

让我们现在通过以下示例将其付诸实践:

const QueryInvalidation = () => {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "userOne" }],
    queryFn: fetchData,
  });
  const queryClient = useQueryClient();
  return (
    <div>
      <p>{data?.hello}</p>
      <button
        onClick={() =>
          queryClient.invalidateQueries({
            queryKey: [{ queryIdentifier: "api" }],
          })
        }
      >
        Invalidate Query
      </button>
    </div>
  );
};

在前面的代码片段中,我们有一个无效化查询的示例。这就是我们正在做的事情:

  1. 创建一个由[{ queryIdentifier: "api", username: "userOne" }]查询键标识的查询

  2. 获取queryClient访问权限

  3. 渲染我们的查询数据和按钮,其中onClick将使所有匹配或包含查询键的一部分[{ queryIdentifier: "api" }]的查询无效

当用户点击查询键的一部分[{ queryIdentifier: "api" }]时,该查询数据将立即被标记为stale。由于此查询正在渲染中,它将自动在后台重新获取。

预取

您希望用户获得最佳的用户体验。这有时意味着在用户意识到之前就了解他们的需求。这正是预取可以帮助您的地方。

当您可以预测用户可能想要执行的操作,这不可避免地触发查询时,您可以利用这些知识并预取查询以节省用户未来的时间。

QueryClient允许您访问一个名为prefetchQuery的方法来预取您的数据。

这里是prefetchQuery方法的语法:

queryClient.prefetchQuery({
      queryKey: ["api"],
      queryFn: fetchData
  });

prefetchQuery需要一个查询键和一个查询函数。这个方法将尝试获取你的数据并将其缓存到给定的查询键下。这是一个异步方法;因此,你需要等待它完成。

现在我们来看一个使用我们的ExamplePrefetching组件进行数据预取的实际例子:

const ExamplePrefetching = () => {
  const [renderComponent, setRenderComponent] =
    useState(false);
  const queryClient = useQueryClient();
  const prefetchData = async () => {
    await queryClient.prefetchQuery({
      queryKey: [{ queryIdentifier: "api", username:
        "userOne" }],
      queryFn: fetchData,
      staleTime: 60000
    });
  };
  return (
    <div>
      <button onMouseEnter={prefetchData} onClick={() =>
      setRenderComponent(true)}> Render Component </button>
      {renderComponent ? <PrefetchedDataComponent /> : null
        }
    </div>
  );
};

在前面的代码片段中,我们创建了我们的ExamplePrefetching组件。以下是它的作用:

  1. 它创建了一个状态变量,我们将使用它来允许我们渲染PrefetchedDataComponent

  2. 它可以访问queryClient

  3. 它创建了一个名为prefetchData的函数,我们在其中调用prefetchQuery方法并将返回的数据缓存到[{ queryIdentifier: "api", username: "userOne" }]查询键下。我们还给它一个staleTime为 1 分钟,所以调用这个查询后,数据将被认为在 1 分钟内是新鲜的。

  4. 创建一个按钮,当点击时,将改变我们的状态变量以允许我们渲染PrefetchedDataComponent。此按钮还有一个onMouseEnter事件,它将触发我们的数据预取。

现在我们来看一下我们的PrefetchedDataComponent组件:

const PrefetchedDataComponent = () => {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "userOne" }],
    queryFn: fetchData,
  });
  return <div>{data?.hello}</div>;
};

在前面的代码片段中,我们可以看到PrefetchedDataComponent。这个组件有一个由[{ queryIdentifier: "api", username: "userOne" }]查询键标识的查询。当这些数据存在时,它将被渲染在div内部。

因此,让我们回顾一下这两个组件的用户流程:

  1. ExamplePrefetching被渲染。

  2. 用户将看到一个写着渲染组件的按钮。

  3. 用户将鼠标放在按钮上准备点击。此时,我们预测用户将点击按钮,因此我们触发数据预取。一旦数据被预取,它就会被缓存到[{ queryIdentifier: "api", username: "userOne" }]查询键下。

  4. 用户点击按钮。

  5. PrefetchedDataComponent被渲染。

  6. [{ queryIdentifier: "api", username: "userOne" }]查询键标识的useQuery钩子已经将数据缓存并标记为在一分钟内是新鲜的,因此不需要触发数据获取。

  7. 用户看到预取的数据被渲染。

查询取消

有时候,当你的useQuery钩子在查询过程中卸载时,你的查询可能会被卸载。默认情况下,一旦你的承诺被解决,这个查询数据仍然会被接收并缓存。但是,出于某种原因,你可能希望在数据获取请求进行到一半时取消你的查询。React Query 可以通过自动取消你的查询来处理这个问题。你也可以手动取消你的查询。

为了允许你取消你的查询,React Query 使用一个可以与 DOM 请求通信并中止它们的信号。这个信号是AbortSignal对象,它属于AbortController Web API。

AbortSignal信号通过QueryFunctionContext注入到我们的查询函数中,然后它应该被我们的数据获取客户端消耗。

这是我们可以如何利用 AbortSignalaxios

const fetchData = async ({ queryKey, signal }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`,
    { signal }
  );
  return data;
};

在前面的段中,我们从 QueryFunctionContext 接收 signal 并将其作为选项在我们的 axios 客户端进行 get 请求时传递。

如果你在一个使用 GraphQL 的场景中使用 axios 的替代品,例如 fetchgraphql-request,你也需要将 AbortSignal 传递给你的客户端。

这就是你可以使用 fetch 来做到这一点的方式:

const fetchDataWithFetch = async ({ queryKey, signal }) => {
  const { username } = queryKey[0];
  const response = await fetch(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`,
    { signal }
  );
  if (!response.ok) throw new Error("Something failed in
    your request");
  return response.json();
};

在前面的段中,我们从 QueryFunctionContext 接收 signal 并将其作为选项传递给我们的 fetch 调用。

如果你使用 graphql-request 这样的 GraphQL 客户端,这是你可以做到这一点的方式:

const fetchGQL = async ({signal}) => {
  const endpoint = <Add_Endpoint_here>;
  const client = new GraphQLClient(endpoint)
  const {
    posts: { data },
  } = await client.request({document: customQuery,
    signal});
  return data;
};

在前面的段中,我们也从 QueryFunctionContext 接收 signal 并将其作为选项传递给我们的客户端请求。

将信号传递给我们的客户端只是允许他们取消查询的第一步。你需要触发自动查询取消或手动取消。

手动取消

对于手动取消查询,QueryClient 提供了访问 **cancelQueries** 方法的权限。

这是 cancelQueries 方法的语法:

queryClient.cancelQueries({ queryKey: ["api"] })

通过调用 cancelQueries,所有匹配或以 ["api"] 开头的当前正在获取且已接收 AbortSignal 的查询都将被中止。

自动取消

当使用你的钩子的组件卸载且你的查询正在获取数据时,如果你向客户端传递 AbortSignal,React Query 将通过取消承诺来中止你的查询。

让我们看看 React Query 如何通过以下示例利用 AbortSignal 来取消你的查询。首先,我们开始配置我们的查询函数:

const fetchData = async ({ queryKey, signal }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`,
    { signal }
  );
  return data;
};

在前面的段中,我们创建了一个 fetchData 函数,该函数将接收 QueryContextObject。从中,我们获取对 signal 的访问权限并将其传递给我们的 axios 客户端。

现在,让我们看看我们的组件:

const ExampleQueryCancelation = () => {
  const [renderComponent, setRenderComponent] =
    useState(false);
  return (
    <div>
      <button onClick={() => setRenderComponent
        (!renderComponent)}>
        Render Component
      </button>
      {renderComponent ? <QueryCancelation /> : null}
    </div>
  );
};

在前面的段中,我们有一个名为 ExampleQueryCancelation 的组件。这个组件将在用户点击按钮的任何地方渲染和卸载一个名为 QueryCancelation 的组件。

让我们现在看看 QueryCancelation 组件:

const QueryCancelation = () => {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "userOne" }],
    queryFn: fetchData,
  });
  const queryClient = useQueryClient();
  return (
    <div>
      <p>{data?.hello}</p>
      <button
        onClick={() =>
          queryClient.cancelQueries({
            queryKey: [{ queryIdentifier: "api" }],
          })
        }
      >
        Cancel Query
      </button>
    </div>
  );
};

段落显示了 QueryCancelation 组件。在这个组件中,我们做以下操作:

  1. 我们创建了一个由 [{ queryIdentifier: "api", username: "userOne" }] 查询键标识的查询。

  2. 我们获取对 QueryClient 的访问权限。

  3. 我们从查询中渲染我们的 data

  4. 我们渲染一个按钮,当点击时,将使用 QueryClient 取消所有匹配或包含其键中的 [{"queryIdentifier": "api"}] 的查询。

让我们现在回顾这些组件的生命周期以及查询取消如何运作:

  1. 我们渲染 ExampleQueryCancelation 组件。

  2. 我们点击按钮以渲染 QueryCancelation 组件。

  3. QueryCancelation 被渲染,其 useQuery 钩子将触发一个请求以获取其数据。

  4. 在这个请求期间,我们再次点击按钮以渲染 QueryCancelation

  5. 由于我们的请求尚未解决且我们的组件已卸载,React Query 将中止我们的信号,这将取消我们的请求。

  6. 我们点击按钮再次渲染QueryCancelation组件。

  7. QueryCancelation被渲染,其useQuery钩子将触发一个请求来获取其数据。

  8. 在这次请求过程中,我们点击按钮取消我们的查询。这将强制 React Query 终止我们的信号并再次取消我们的请求。

因此,我们已经看到了QueryClient及其一些方法如何帮助我们解决一些常见的服务器状态挑战。

在下一节中,我们将看到 React Query 如何允许我们构建一个常见的 UI 模式,即分页查询。

创建分页查询

当构建一个处理大量数据的 API 时,为了避免你的前端一次性处理所有内容,你不想在一个请求中发送所有可用的数据。一种常用的模式是 API 分页。

如果你的 API 是分页的,你希望将相同的模式应用到你的应用程序中。

好处在于,你只需要使用useQuery及其一个选项,keepPreviousData

让我们看看接下来的示例,然后了解分页和 React Query 是如何工作的。首先,我们从我们的查询函数开始:

const fetchData = async ({ queryKey }) => {
  const { page } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-paginated?page=${page}&results=10`
  );
  return data;
};

在前面的代码片段中,我们创建了一个将用作查询函数的函数。由于这是一个分页 API,我们需要page来获取我们的数据。正如我们在上一章中建立的,如果变量是查询的依赖项,则需要将其添加到查询键中。然后,我们在查询函数中从查询键中解构page。然后,我们只需要获取我们的数据,并在承诺解决时返回它。

现在,让我们看看我们如何构建一个用于显示和获取分页数据的组件:

const PaginatedQuery = () => {
  const [page, setPage] = useState(0);
  const { isLoading, isError, error, data, isFetching,
    isPreviousData } =
    useQuery({
      queryKey: [{ queryIdentifier: "api", page }],
      queryFn: fetchData,
      keepPreviousData: true,
    });
  if (isLoading) {
    return <h2>Loading initial data...</h2>;
  }
  if (isError) {
    return <h2>{error.message}</h2>;
  }
  return (
    <>
      <div>
        {data.results.map((user) => (
          <div key={user.email}>
            {user.name.first}
            {user.name.last}
          </div>
        ))}
      </div>
      <div>
      <button
        onClick={() => setPage((oldValue) => oldValue === 0
          ? 0 : oldValue - 1)}
        disabled={page === 0}
      >
        Previous Page
      </button>
      <button
        disabled={isPreviousData}
        onClick={() => {
          if (!isPreviousData) setPage((old) => old + 1);
        }}
      >
        Next Page
      </button>
      </div>
      {isFetching ? <span> Loading...</span> : null}
    </>
  );
};

让我们回顾一下前面代码块中发生的事情:

  1. 我们创建一个状态变量来保存我们当前选中的page

  2. 我们创建我们的查询,它具有[{ queryIdentifier: "api", page }]查询键,我们的fetchData函数作为查询函数,并将keepPreviousData设置为true。我们将此选项设置为true是因为,默认情况下,每当我们的查询键更改时,查询数据也会更改;现在,由于我们有一个分页 API,我们希望即使在更改页面时也能继续显示我们的数据。

  3. 然后,我们解构isLoadingisErrorerrordataisFetchingisPreviousDataisPreviousData用于指示当前显示的数据是否是上一个版本。

  4. 我们有两个if语句来显示我们的查询何时正在加载,或者何时出现错误。

  5. 如果我们有数据,我们显示它,并有两个按钮用于移动到下一页和上一页。用于移动到下一页的按钮利用isPreviousData确保我们在点击并移动到后续查询后将其禁用。我们还显示一个获取指示器。

现在我们已经看到了代码的结构,让我们看看当与之交互时的行为:

  1. 我们的组件被渲染,第一页开始被获取。

isLoading属性被设置为true,因此我们渲染Loadinginitial data

  1. 第一页的数据已解析,因此我们显示它。

  2. 我们点击 page 值会增加。

  3. 查询键发生变化,因此接下来的查询开始获取。

  4. 由于我们将 keepPreviousData 设置为 true,我们仍然会显示旧数据。

  5. 由于我们正在显示旧数据,isPreviousData 被设置为 true,并且显示 Loading

  • 我们获取新数据并显示它。* 我们点击 Loading。* 新数据被接收并显示。

如您所见,您只需要一个新的选项和相同的旧 useQuery 钩子,就可以构建一个使用分页的应用程序。

在下一节中,让我们看看如何构建无限查询。

创建无限查询

另一个非常常见的 UI 模式是构建无限滚动组件。在这个模式中,我们看到一个列表,允许我们在向下滚动时加载更多数据。

为了处理这些类型的列表,React Query 提供了 useQuery 钩子的一个替代品,这是一个名为 useInfiniteQuery 的自定义钩子。

使用 useInfiniteQuery 钩子与 useQuery 钩子有很多相似之处,但也有一些不同之处,我们需要注意:

  • 您的数据现在是一个包含以下内容的对象:

    • 获取的页面

    • 用于获取页面的 page 参数

  • 一个名为 fetchNextPage 的函数,用来获取下一页

  • 一个名为 fetchPreviousPage 的函数,用来获取上一页

  • 一个名为 isFetchingNextPage 的布尔状态,用来指示下一页正在被获取

  • 一个名为 isFetchingPreviousPage 的布尔状态,用来指示下一页正在被获取

  • 一个名为 hasNextPage 的布尔状态,用来指示列表是否有下一页

  • 一个名为 hasPreviousPage 的布尔状态,用来指示列表是否有上一页

这最后两个布尔值取决于可以传递给钩子的两个选项。分别是 getNextPageParamgetPreviousPageParam。这些函数将负责选择缓存中的最后一页或第一页,并检查其数据是否指示要获取下一页或上一页。如果这些值存在,则相应的布尔值将为 true。如果它们返回 undefined,则布尔值将为 false

要使用 useInfiniteQuery 钩子,您需要以这种方式导入它:

import { useInfiniteQuery } from "@tanstack/react-query"

现在让我们看看如何使用 useInfiniteQuery 钩子构建一个无限列表的示例:

const fetchData = async ({ pageParam = 1 }) => {
    const { data } = await axios.get(
        `https://danieljcafonso.builtwithdark.com/
         react-query-infinite?page=${pageParam}&results=10`
    );
    return data;
  };

在前面的代码片段中,我们设置了用作无限查询函数的函数。钩子将传递 pageParamQueryFunctionContext,这样我们就可以利用它来获取我们的数据。像 useQuery 钩子中的查询函数一样,这个查询函数需要解决数据或抛出错误,因此所有之前学到的原则都适用。

下一个代码片段将展示我们的 InfiniteScroll 组件:

const InfiniteScroll = () => {
  const {
    isLoading,
    isError,
    error,
    data,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: ["api"],
    queryFn: fetchData,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.info.nextPage;
    },
  });
  if (isLoading) {
    return <h2>Loading initial data...</h2>;
  }
  if (isError) {
    return <h2>{error.message}</h2>;
  }
  return (
    <>
      <div>
        {data.pages.map((page) =>
          page.results.map((user) => (
            <div key={user.email}>
              {user.name.first}
              {user.name.last}
            </div>
          ))
        )}
      </div>
      <button
        disabled={!hasNextPage || isFetchingNextPage}
        onClick={fetchNextPage}
      >
        {isFetchingNextPage
          ? «Loading...»
          : hasNextPage
          ? «Load More»
          : «You have no more data»}
      </button>
    </>
  );
};

在前面的代码片段中,我们有一个渲染无限列表的组件。这就是我们在组件中做的事情:

  1. 我们创建useInfiniteQuery,它以["api"]作为查询键和fetchData作为查询函数。它还接收一个匿名函数在getNextPageParam选项中,以检查下一页是否还有更多数据要加载。

  2. 我们还从钩子中解构出构建我们的应用程序所需的某些变量。

  3. 然后我们有两个if语句来显示我们的查询正在加载或存在错误时的情况。

  4. 当我们有数据时,我们将其page属性内的内容映射以渲染我们的列表。

  5. 我们还渲染了一个按钮,如果我们没有下一页或我们正在获取下一页时,该按钮将被禁用。当点击时,此按钮将获取更多数据。此按钮消息也将取决于一些约束:

    • 如果我们在获取数据,下一页将显示一个加载消息

    • 如果我们有下一页,它将显示加载更多,以便用户可以点击它开始获取

    • 如果没有更多数据可以获取,它将显示一条消息,告知用户没有更多数据

正如我们刚刚回顾了组件的构建方式,让我们看看它与交互时的表现:

  1. 我们的组件渲染,列表的第一页将自动获取:

    • isLoading属性设置为true,所以我们渲染加载 初始数据
  2. 列表的第一页数据已解析,所以我们显示它。

  3. 同时,getNextPageParam函数检查列表中是否有更多数据。

  4. 如果没有更多数据,hasNextPage属性设置为false,获取更多数据的按钮被禁用并显示您没有 更多数据

  5. 如果有更多数据,hasNextPage属性设置为true,用户可以点击按钮来获取更多数据。

  6. 如果用户点击按钮,我们将看到以下内容:

    1. 下一页开始获取。

    2. isFetchingNextPage的值变为true

    3. 按钮被禁用并显示加载消息。

    4. 数据已解析,并且我们的数据pages属性长度增加,因为它包含了新页面的数据。步骤 3、4、5被重复。

通过这种方式,我们刚刚看到了useQuery变体useInfiniteQuery如何让我们直接构建无限列表。

在我们结束这一章之前,让我们最后看看我们如何使用 React Query Devtools 来帮助我们调试代码并查看我们的查询行为。

使用 Devtools 调试查询

第三章中,你学习了关于 React Query Devtools 的内容。在那个阶段,你还不知道如何使用查询,所以我们无法看到它的工作情况。现在我们可以了。

对于你接下来要看到的图像,我们将利用我们在动态并行 查询部分向你展示useQueries钩子示例时编写的代码。

为了让您记住,这里是有代码:

const usernameList = ["userOne", "userTwo", "userThree"];
const ExampleTwo = () => {
  const multipleQueries = useQueries({
    queries: usernameList.map((username) => {
      return {
        queryKey: [{ queryIdentifier: "api", username }],
        queryFn: fetchData,
      };
    }),
  });
  return (
    <div>
      {multipleQueries.map(({ data, isFetching }) => (
        <p>{isFetching ? "Fetching data..." : data.hello}
          </p>
      ))}
    </div>
  );
};

当使用该代码并检查我们的页面时,Devtools 将向我们展示以下内容:

图 5.1 – 执行并行查询后的 React Query Devtools

图 5.1 – 执行并行查询后的 React Query Devtools

在前面的图中,我们可以看到以下内容:

  • 我们有三个查询

  • 每个查询都通过各自的查询键来标识

  • 所有查询目前都是过时的

  • 我们已选择了以[{ queryIdentifier: "api", username: "userThree" }]查询键标识的查询

当我们选择一个查询时,我们可以在我们的查询详情标签页中看到查询详情。

在前面的图中,我们可以看到这个查询通过其查询键和状态来标识。

查询详情标签页向下滚动,我们还能看到以下内容:

图 5.2 – React Query Devtools 查询详情标签页显示操作和数据探索器

图 5.2 – React Query Devtools 查询详情标签页显示操作和数据探索器

在前面的图中,我们可以看到我们可以为选定的查询执行几个操作,例如重新获取、使无效、重置和删除它。

我们也能看到这个查询的当前数据。

在我们的查询详情标签页进一步向下滚动,我们还可以检查查询探索器

图 5.3 – React Query Devtools 查询详情标签页显示查询探索器

图 5.3 – React Query Devtools 查询详情标签页显示查询探索器

在前面的图中,我们可以看到cacheTime300000

您现在已经了解在 Devtools 中为每个选定的查询可以看到什么。

在结束本节之前,让我们看看点击查询详情操作中可用的按钮之一会发生什么:

图 5.4 – React Query Devtools 当前正在获取查询

图 5.4 – React Query Devtools 当前正在获取查询

在前面的图中,我们点击了[{ queryIdentifier: "api", username: "userTwo" }]查询键。

如您在学习查询无效化时记得的那样,当我们使查询无效时,它会自动标记为过时,如果查询当前正在渲染,它将自动重新获取。从图中可以看出,这就是发生的情况。我们的查询已经过时,因此没有必要再次将其标记为过时,但由于它目前正在我们的页面上渲染,React Query 负责重新获取它,我们可以在图中看到这一点。

如您在本节中看到的那样,Devtools 可以为您节省大量调试查询的时间。通过查看您的查询,如果您已配置了正确的选项,您可以检查其数据看起来如何,甚至可以触发一些操作。

摘要

在本章中,我们学习了更多关于使用useQuery钩子来解决我们在处理服务器状态时遇到的一些常见挑战。到现在为止,您可以轻松处理所有数据获取需求。

你学习了并行查询,并了解到你可以使用useQuery手动构建这些查询。你还被介绍到useQuery钩子的一种替代方案:useQueries。通过它,你学习了如何构建动态并行查询。

你需要更多地了解一些QueryClient的方法,这些方法允许你预取、取消和使查询无效,并且你也理解了如何利用QueryFilters来自定义这些方法中使用的查询匹配。

分页是一个典型的 UI 模式,现在你知道你可以借助useQuery及其一个选项轻松构建分页组件。

另一个典型的 UI 模式是无限滚动。借助另一个名为useInfiniteQueryuseQuery变体,你学习了 React Query 如何让你构建一个具有无限列表的应用程序。

最后,你使用 React Query Devtools 检查了你的查询,并理解了它如何允许你调试它们并改进你的开发过程。

第六章使用 React Query 执行数据突变中,我们将放下数据获取,转向突变。你将理解 React Query 如何通过其名为useMutation的自定义钩子帮助你执行突变。你还将利用这个钩子来处理你在应用程序中遇到的更常见的服务器状态挑战,并通过使用乐观更新来开始构建更好的用户体验。

第六章:使用 React Query 执行数据突变

在构建应用程序时,你并不总是需要获取数据。有时,你可能想要创建、更新或删除数据。在这些操作中,你的服务器状态将需要改变。

React Query 允许你通过使用突变来更改你的服务器状态。要执行突变,你可以利用 React Query 的另一个自定义钩子,称为useMutation

在本章中,你将介绍useMutation钩子,并了解 React Query 如何允许你创建、更新和删除你的服务器状态。类似于第四章,在这个过程中,你将了解你在突变中使用的所有默认值。你还将了解一些可以用来改进你的useMutation体验的选项。

一旦你熟悉了useMutation,你将了解如何利用它的一些选项来执行一些副作用模式,例如手动更新数据或强制查询在执行突变后更新。

在本章结束时,我们将把到目前为止所学的一切整合起来,并将其应用于做一些可能显著提高用户体验的事情:乐观更新。

在本章中,我们将涵盖以下主题:

  • useMutation是什么以及它是如何工作的?

  • 突变后的副作用模式

  • 执行乐观更新

技术要求

本章的所有代码示例都可以在 GitHub 上找到,地址为github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_6

useMutation是什么以及它是如何工作的?

到现在为止,你必须已经意识到突变允许你对服务器状态进行更新。这些更新可以是创建数据、删除数据或编辑数据等操作。

为了让你能够在服务器数据上执行突变,React Query 创建了一个名为useMutation的钩子。

现在,与默认情况下会自动运行查询的useQuery不同,useMutation只有在调用从钩子实例化返回的一个函数(称为mutate)时才会运行你的突变。

要使用useMutation钩子,你必须像这样导入它:

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

一旦导入,你就可以用它来定义你的突变。以下是useMutation的语法:

const mutation = useMutation({
    mutationFn: <InsertMutationFunction>
})

如您从前面的代码片段中看到的,useMutation钩子只需要一个必需参数才能工作,即突变函数。

突变函数是什么?

突变函数是一个返回负责执行异步任务的 promise 的函数。在这种情况下,这个异步任务将是我们的突变。

我们之前看到的与查询函数相同的原理也适用于 mutation 函数。这意味着,正如我们看到的查询函数一样,由于这个函数只需要返回一个 promise,它再次允许我们使用我们选择的任何异步客户端。这意味着 REST 和 GraphQL 仍然受支持,所以如果你愿意,你可以同时使用这两个选项。

现在我们来看一个使用 GraphQL 和 REST 的 mutation 函数的示例。这些 mutation 函数将被用来在我们的服务器状态中创建新用户:

使用 GraphQL 的 mutation

import { useMutation } from "@tanstack/react-query";
import { gql, GraphQLClient } from "graphql-request";
const customMutation = gql`
mutation AddUser($user: String!, $age: Int!) {
  insert_user(object: { user: $user, age: $age }) {
    user
    age
  }
}
`
const createUserGQL = async (user) => {
  const endpoint = <add_endpoint_here>;
  const client = new GraphQLClient(endpoint)
  return client.request(customMutation, user);
  return data;
};
 ...
const mutation = useMutation({
    mutationFn: createUserGQL
  });

前面的代码片段展示了使用 React Query 创建 GraphQL mutation 的示例。以下是我们的操作:

  1. 我们首先创建我们的 GraphQL mutation,并将其分配给我们的customQuery变量。

  2. 然后我们创建createUserGQL函数,这个函数将成为我们的 mutation 函数。这个函数也将接收作为参数的user数据,这些数据将被我们的 mutation 用于在服务器上创建数据。

  3. 在我们的useMutation钩子中,我们将createUserGQL函数作为 mutation 函数传递给钩子。

让我们看看如何使用 REST 来完成这个操作:

使用 REST 的 mutation

import axios from "axios";
import {useMutation} from "@tanstack/react-query";
const createUser = async (user) => {
  return axios.post
    (`https://danieljcafonso.builtwithdark.com/name-api`,
      user);
};
 …
const mutation = useMutation({
    mutationFn: createUser
  });

在前面的代码片段中,我们可以看到一个使用 React Query 创建 REST 的 mutation 的示例。以下是我们的操作:

  1. 我们首先创建createUser函数,这个函数将成为我们的 mutation 函数。这个函数将接收作为参数的user数据,这些数据用于我们的 mutation 在服务器上创建数据。在这里,我们知道我们将使用POST方法在服务器上创建数据。

  2. 在我们的useMutation钩子中,我们将createUser函数作为 mutation 函数传递给钩子。

在前面的例子中,我们使用了axios,但如果你更喜欢使用fetch而不是axios,你只需要在createUser函数内部将axios替换为fetch,并对fetch进行必要的修改以使其工作。以下是一个使用fetch的示例:

const createUserFetch = async (user) => {
  return fetch
    (`https://danieljcafonso.builtwithdark.com/name-api`, {
    method: "POST",
    body: JSON.stringify(user),
    headers: {
      "Content-type": "application/json; charset=UTF-8",
    },
  });
};
const mutation = useMutation({
    mutationFn: createUserFetch
});

在前面的代码片段中,我们可以看到之前展示的createUser函数的示例,但这次我们使用了fetch而不是axios

现在我们已经熟悉了 mutation 函数,我们需要了解useMutation钩子如何利用这个函数来允许我们执行 mutations。在下一节中,我们将学习mutate函数如何使我们能够这样做,以及useMutation返回的其他内容。

useMutation 返回什么?

useQuery一样,当使用useMutation钩子时,它返回几个值。

如本章前面所述,要执行 mutations,我们需要利用mutate。现在,mutate不是执行 mutations 的唯一方式,也不是useMutation返回的唯一内容。

在本节中,我们将回顾useMutation钩子的以下返回值:

  • mutate

  • mutateAsync

  • 数据

  • 错误

  • 重置

  • 状态

  • isPaused

mutate

在使用你的 useMutation 钩子创建你的变更后,你需要一种方法来触发它。mutate 是你几乎每次都需要用来做到这一点的函数。

这就是如何使用 mutate

const { mutate } = useMutation({
    mutationFn: createUser
  });
mutate({ name: "username", age: 25 })

在这个代码片段中,我们做以下操作:

  1. 我们从 useMutation 钩子中解构出我们的 mutate 函数。

  2. 我们使用 mutate 函数并传递变量,这些变量是我们期望 mutate 函数接收以执行我们的变更。

就这样;这就是你如何使用 React Query 来执行变更。你创建你的变更函数,将其传递给你的 useMutation 钩子,从其中解构出 mutate,并使用所需的参数调用它以执行你的变更。

现在,前面的代码片段旨在展示如何通过使用 mutate 来触发变更,但这不是一个非常实用的例子。为了帮助你构建如何使用 mutate 来执行变更的心理模型,你可以参考以下代码片段:

import axios from "axios";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
const createUser = async (user) => {
  return axios.post
    (`https://danieljcafonso.builtwithdark.com/name-api`,
      user);
};
const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <button onClick={submitForm}>Add</button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们可以看到一个使用受控组件的简单表单示例。这就是前面代码片段中发生的事情:

  1. 我们创建一个 createUser 变更函数,它将接收一个包含一些数据的 user 对象。

在这个函数内部,我们返回 axios 客户端的 post 方法的调用,这将返回 useMutation 期望接收的用于变更函数的承诺。

  1. 在我们的 SimpleMutation 组件内部,我们做以下操作:

    1. 我们创建一个状态变量来控制输入的状态。

    2. 我们使用 createUser 函数作为变更函数来创建我们的变更,并从其中解构出 mutate

    3. 我们创建一个 submitForm 函数。这个函数将接收表单的事件并阻止其传播,这样你的页面就不会刷新。在处理事件后,它通过调用 mutate 触发变更,并将 name 状态变量作为 user 对象的一部分传递。

  2. 在我们的表单内部,我们创建我们的输入来处理 name 并让 React 控制其状态。

  3. 我们创建一个带有 onClick 事件的按钮来触发我们的 submitForm 函数。

正如你应该从前面的解释和代码中理解的那样,每当我们点击使用当前输入值的 POST 请求到我们的 URL 时。

在继续本章的过程中,你还会看到 mutate 也可以接收一些选项来执行副作用,如果你需要的话。但让我们把这些细节留到以后再说。

虽然 mutate 是在 React Query 中执行变更的基础,但如果你愿意,你也可以使用另一个函数:mutateAsync

mutateAsync

在大多数情况下,你会使用 mutate,但有时你可能想访问包含你变更结果的承诺。在这些情况下,你可以使用 mutateAsync

在使用 mutateAsync 时,需要注意的一点是你需要自己处理承诺。这意味着在错误场景中,你需要捕获错误。

这就是如何使用 mutateAsync 函数:

const { mutateAsync } = useMutation({
  mutationFn: createUser,
});
try {
  const user = await mutateAsync({ name: "username", age:
    25 });
} catch (error) {
  console.error(error);
}

在前面的代码片段中,我们从useMutation钩子中解构了mutateAsync函数:

  • 我们需要处理潜在的错误场景,因此我们将mutateAsync调用用try-catch语句包裹起来。由于这是一个异步函数,我们必须等待数据返回。

  • 如果出现错误,我们会捕获它并在我们的控制台中显示错误。

前面的代码片段显示了如何使用mutateAsync触发突变;正如我们在mutate中所示,这似乎不是一个非常实用的例子。为了帮助你创建如何使用mutateAsync执行突变的心理模型,你可以看到以下代码片段:

const ConcurrentMutations = () => {
  const [name, setName] = useState("");
  const { mutateAsync: mutateAsyncOne } = useMutation({
    mutationFn: createUser,
  });
  const { mutateAsync: mutateAsyncTwo } = useMutation({
    mutationFn: registerUser,
  });
  const submitForm = async (e) => {
    e.preventDefault()
    const mutationOne = mutateAsyncOne({ name })
    const mutationTwo = mutateAsyncTwo({ name })
     try {
      const data = await Promise.all([mutationOne,
        mutationTwo]);
      // do something with data
    } catch (error) {
      console.error(error);
    }
  }
  return (
    <div>
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <button onClick={submitForm}>Add</button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们可以看到一个使用受控组件的简单表单示例,其中我们利用mutateAsync执行并发突变。这就是代码中发生的事情:

  1. 我们创建一个状态变量来控制输入的状态。

  2. 我们使用createUser函数作为突变函数来创建我们的第一个突变,并从其中解构mutateAsyncmutateAsyncOne

  3. 我们使用registerUser函数作为突变函数来创建我们的第二个突变,并从其中解构mutateAsyncmutateAsyncTwo

  4. 我们创建一个submitForm函数:

    1. 这个函数将接收来自表单的事件并阻止其传播,这样你的页面就不会刷新。

    2. 我们将调用mutationAsyncOne并传递name作为参数返回的承诺分配给我们的mutationOne变量。

    3. 我们将调用mutationAsyncTwo并传递name作为参数返回的承诺分配给我们的mutationTwo变量。

    4. 我们利用Promise.all方法并将其传递给我们的mutationOnemutationTwo承诺,以便它们可以并发执行。

  5. 在我们的表单内部,我们创建输入来处理我们的名称,并让 React 控制其状态。

  6. 我们创建一个带有onClick事件的按钮来触发我们的submitForm函数。

现在,你已经熟悉了如何执行突变,让我们回顾一下受突变成功影响的变量,data

data

这个变量是突变函数返回的最后成功解析的data

这里是如何使用data变量的示例:

const SimpleMutation = () => {
  const { mutate, data } = useMutation({
    mutationFn: createUser,
  });
  return (
    <div>
        {data && <p>{data.data.user}</p>}
      ...
    </div>
  );
}

在这个代码片段中,我们做了以下操作:

  1. 我们从useMutation钩子中解构了我们的data变量。

  2. 在我们的组件返回时,我们检查是否已经从我们的突变中获取了data。如果是,我们就渲染它。

当钩子最初渲染时,这个data将是未定义的。一旦突变触发并完成执行,并且突变函数返回的承诺成功解析了我们的数据,我们就可以访问data。如果由于某种原因突变函数的承诺被拒绝,我们可以使用下一个变量,即error变量。

error

error变量让你可以访问突变函数返回的失败后的error对象。

这里是如何使用error变量的示例:

const SimpleMutation = () => {
  const { mutate, error } = useMutation({
    mutationFn: createUser,
  });
  return (
    <div>
        {error && <p>{error.message}</p>}
  ...
    </div>
  );
};

在前面的代码片段中,我们做了以下操作:

  1. 我们从useQuery钩子中解构我们的error变量。

  2. 在我们的组件返回时,我们检查是否有任何错误。如果有,我们将渲染错误信息。

当钩子最初渲染时,error值将是 null。如果在突变之后,由于某种原因突变函数拒绝并抛出错误,那么这个错误将被分配给我们的error变量。在这里重要的是要提到,这仅适用于你使用mutate的情况。如果你使用mutateAsync,你必须自己捕获错误并处理它。

当使用error变量时,有时为了用户体验,你可能想要清除你的错误。在这些情况下,reset函数将成为你的最佳选择。

reset

reset函数允许你将errordata重置到它们的初始状态。

这个函数在你需要在运行突变后清除当前数据或错误值时很有用。

这是你可以使用reset函数的方法:

const SimpleMutation = () => {
  const { mutate, data, error, reset } = useMutation({
    mutationFn: createUser,
  });
  return (
    <div>
        {error && <p>{error.message}</p>}
        {data && <p>{data.data.user}</p>}
        <button onClick={() => reset()}>Clear errors and
          data</button>
        ...
   </div>
  );
};

在这个片段中,我们做了以下操作:

  1. 我们从useMutation钩子中解构dataerror变量和reset函数。

  2. 在我们的组件返回时,我们检查是否已经从我们的突变中获得了数据或错误。当且仅当我们这样做时,我们将渲染它们。

  3. 我们还渲染了一个带有onClick事件的按钮。当点击这个按钮时,它将触发我们的reset函数来清除我们的dataerror值。

现在,为了使用errordata变量,我们只需在代码中检查它们是否已定义,以便我们可以渲染它们。为了使这更容易,并且再次帮助你为你的应用程序制作更好的用户体验,你可以求助于使用status变量。

status

就像查询一样,当执行突变时,突变可以经过几个状态。这些状态帮助你向用户提供更多反馈。为了知道你的突变当前的状态,我们创建了status变量。

status变量可以具有以下状态:

  • idle:这是你的突变在执行之前的初始状态。

  • loading:这表示你的突变是否正在执行。

  • error:这表示在执行最后一个突变时出现了错误。每当这是状态时,error属性将接收从突变函数返回的错误。

  • success:你的最后一个突变是成功的,并且它已经返回了数据。每当这是状态时,data属性将接收从突变函数返回的成功数据。

这是你可以使用status变量的方法:

 const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate, status, error, data } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      {status === "idle" && <p> Mutation hasn't run </p>}
      {status === "error" && <p> There was an error:
        {error.message} </p>}
      {status === "success" && <p> Mutation was successful:
        {data.name} </p>}
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <button disabled={status === "loading"}
          onClick={submitForm}>Add</button>
      </form>
    </div>
  );
};

在前面的片段中,我们正在利用status变量来为我们的用户提供更好的用户体验。以下是我们在做些什么:

  1. 我们创建了一个状态变量来处理我们的受控表单。

  2. 我们创建我们的突变并从useMutation钩子中解构status

  3. 我们创建了一个submitForm函数来处理我们的突变提交。

  4. 我们利用我们的status变量在我们的组件返回中执行以下操作:

    1. 如果statusidle,我们将渲染一条消息,让用户知道我们的突变尚未运行。

    2. 如果status等于error,我们必须解构我们的error变量并显示错误消息。

    3. 如果status等于success,我们必须解构我们的data变量并将其显示给我们的用户。

    4. 如果status等于loading,这意味着我们正在执行一个突变,因此我们使用这个选项来确保我们禁用我们的添加按钮,避免在突变运行期间用户再次点击它。

现在,您知道了如何使用status变量。为了方便,React Query 还引入了一些布尔变体,以帮助识别每个状态。它们如下所示:

  • isIdle:您的status变量处于空闲状态

  • isLoading:您的status变量处于加载状态

  • isError:您的status变量处于错误状态

  • isSuccess:您的status变量处于成功状态

让我们现在重写我们之前的代码片段,利用我们的status布尔变体:

 const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate, isIdle, isError, isSuccess, isLoading,
    error, data } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      {isIdle && <p> Mutation hasn't run </p>}
      {isError && <p> There was an error: {error.message}
        </p>}
      {isSuccess && <p> Mutation was successful:
        {data.name} </p>}
      <form>
        <input
          name="name"
          type={"text"}
          onChange={€ => setName(e.target.value)}
          value={name}
        />
        <button disabled={isLoading} onClick={submitForm}>
          Add</button>
      </form>
    </div>
  );
};

如您所见,代码是相似的。我们只需在解构部分将我们的status变量替换为isLoadingisErrorisSuccessisIdle,然后在相应的状态检查中使用这些变量。

与查询不同,突变没有fetchStatus变量。这并不意味着您的突变不能因突然断开互联网连接而受到影响。为了给用户提供更多反馈,我们创建了isPaused变量。

isPaused

如您应从第四章中记住,React Query 引入了一个名为networkMode的新属性。当在线模式下使用时,您可以在useMutation钩子中访问一个新变量,称为isPaused

这个布尔变量标识您的突变是否因断开连接而当前暂停。

让我们看看如何使用isPaused变量:

const SimpleMutation = () => {
  const [name, setName] = useState("");
  const { mutate, isPaused } = useMutation({
    mutationFn: createUser,
  });
  const submitForm = € => {
    e.preventDefault()
    mutate({ name })
  }
  return (
    <div>
      {isPaused && <p> Waiting for network to come back </p>}
      <form>
        <input
          na"e="n"me"
          typ"={"t"xt"}
          onChang€(e) => setName(e.target.value)}
          value={name}
        />
        <button disabled={isPaused} onClick={submitForm}>
          Add</button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们利用isPaused变量来在我们的应用程序中创建更好的用户体验:

  1. 我们从useMutation钩子中解构我们的isPaused变量。

  2. 在我们的组件返回中,我们检查isPaused是否为true。如果是,我们渲染一条消息让我们的用户知道。我们还将其分配给禁用我们的添加按钮,以避免用户意外触发另一个突变。

现在我们知道了useMutation钩子返回的一些值,让我们看看我们如何使用一些选项来自定义这个钩子。

常用突变选项解释

useQuery钩子类似,我们可以向useMutation钩子传递比其突变函数更多的选项。这些选项也将帮助我们创建更好的开发者和用户体验。

在本节中,我们将看到一些更常见且非常重要的选项。

这里是我们将要查看的选项:

  • cacheTime

  • mutationKey

  • retry

  • retryDelay

  • onMutate

  • onSuccess

  • onError

  • onSettled

cacheTime

cacheTime选项是您的缓存中不活跃数据在内存中保持的时间(以毫秒为单位)。一旦这个时间过去,数据将被垃圾回收。请注意,这与查询的方式不同。如果您执行了一个突变,返回的数据将被缓存,但如果在突变挂起期间再次执行相同的突变,useMutation将不会返回之前的突变数据。在突变中,此选项主要用于防止之前的突变数据无限期地保留在MutationCache中。

下面是如何使用cacheTime选项:

useMutation({
  cacheTime: 60000,
});

在这个代码片段中,我们定义了在突变不活跃一分钟之后,数据将被垃圾回收。

mutationKey

有时您会想通过利用您的queryClientsetMutationDefaults来为所有突变设置一些默认值。

mutationKey选项允许 React Query 知道是否需要将之前配置的默认值应用于此突变。

下面是如何使用mutationKey选项:

useMutation({
  mutationKey: ["myUserMutation"],
});

在前面的代码片段中,我们使用["myUserMutation"]作为突变键创建了一个突变。如果为任何具有["myUserMutation"]作为突变键的突变配置了默认值,它们现在将被应用。

重试

retry选项是一个值,表示当突变失败时,您的突变是否会重试。当为true时,它会重试直到成功。当为false时,它不会重试。

此属性也可以是一个数字。当它是一个数字时,突变将重试指定次数。

默认情况下,React Query 不会在出错时重试突变。

下面是如何使用retry选项:

useMutation({
  retry: 2,
});

在这个代码片段中,我们将retry选项设置为2。这意味着当执行突变失败时,此钩子将重试执行突变两次。

retryDelay

retryDelay选项是在下一次重试尝试之前应用的延迟(以毫秒为单位)。

默认情况下,React Query 使用指数退避延迟算法来定义重试之间的时间间隔。

下面是如何使用retryDelay选项:

useMutation({
  retryDelay: (attempt) => attempt * 2000,
});

在代码片段中,我们定义了一个线性退避函数作为我们的retryDelay选项。每次重试时,此函数都会接收尝试次数并将其乘以 2,000。这意味着每次重试之间的时间将增加两秒。

onMutate

onMutate选项是一个在您的突变函数被触发之前会调用的函数。此函数还会接收您的突变函数将接收的变量。

您可以从这个函数返回值,这些值将被传递到您的onErroronSettled回调函数中:

useMutation({
  onMutate: (variables) => showNotification("Updating the
    following data:", variables),
});

在这个代码片段中,我们将一个箭头函数传递到我们的onMutate选项中。当我们的突变被触发时,分配给onMutate选项的函数将使用这些变量调用,然后我们使用这些变量向用户显示有关挂起突变的提示。

onSuccess

onSuccess选项是一个函数,当你的突变成功时将被触发。

如果此函数返回一个承诺,它将被等待并解决。这意味着你的突变状态将处于加载状态,直到承诺解决。

这是如何使用onSuccess选项的方法:

useMutation({
  onSuccess: (data) => console.log("mutation was
    successful", data),
});

在代码片段中,我们向onSuccess选项传递一个箭头函数。当我们的突变成功执行时,分配给onSuccess选项的此函数将使用我们的数据被调用。然后我们使用这些数据在我们的控制台中记录一条消息。

onError

onError选项是一个函数,当你的突变失败时将被触发。

如果此函数返回一个承诺,它将被等待并解决。这意味着你的突变状态将处于加载状态,直到承诺解决。

这是如何使用onError选项的方法:

useMutation({
  onError: (error) => console.log("mutation was
    unsuccessful", error.message),
});

在代码片段中,我们向onError选项传递一个箭头函数。当突变失败时,分配给onError选项的此函数将使用抛出的错误被调用。然后我们在我们的控制台中记录错误。

onSettled

onSettled选项是一个函数,当你的突变成功或失败时将被触发。

如果此函数返回一个承诺,它将被等待并解决。这意味着你的突变状态将处于加载状态,直到承诺解决。

这是如何使用onSettled选项的方法:

useMutation({
  onSettled : (data, error) => console.log("mutation has
    settled"),
});

在代码片段中,我们向onSettled选项传递一个箭头函数。当突变成功或失败时,分配给onSettled选项的此函数将使用抛出的错误或解决的数据被调用。然后我们在我们的控制台中记录一条消息。

到目前为止,你应该已经熟悉了useMutation钩子的用法,并且应该能够开始使用它来创建、更新或删除你的服务器状态数据。现在,让我们看看我们如何利用这个钩子和一些选项来执行一些常见的副作用模式。

在突变之后执行副作用模式

当你阅读这个标题时,你可能想知道你是否以前见过如何在突变之后执行副作用。答案是肯定的,你已经做到了。要执行突变后的副作用,你可以利用这些选项中的任何一个:

  • onMutate

  • onSuccess

  • onError

  • onSettled

现在,你可能还没有看到如何利用这些副作用来做一些可能改善用户体验的惊人事情,比如执行多个副作用、重新获取查询,甚至在突变后更新查询数据。

在本节中,我们将回顾一些我们如何利用useMutation钩子的回调函数以及更多内容来执行之前提到的副作用。

如何执行额外的副作用

在开发过程中,可能会出现一个场景,如果你能够执行两个 onSuccess 回调将会很有用。现在,你当然可以在你的 useMutation 钩子回调中添加你想要的任何逻辑,但如果你想要拆分逻辑或者只在一个单独的变异上执行这个特定的逻辑呢?这确实很有用,因为你可以分离关注点和逻辑。嗯,你当然可以做到!

mutate 函数允许你创建自己的回调函数,这些函数将在你的 useMutation 回调之后执行。

你只需要意识到你的 useMutation 回调先执行,然后是你的 mutate 函数回调。这一点很重要,因为有时候如果你在 useMutation 回调中做了某些导致钩子卸载的操作,你的 mutate 函数回调可能不会执行。

下面是一个如何使用 mutate 回调函数的例子:

 const { mutate } = useMutation({
    mutationFn: createUser,
    onSuccess: (data) => {
      showToast(`${data.data.name} was created
        successfuly`)
    }
  });
  const submitForm = (e) => {
    e.preventDefault()
    mutate({ name }, {
      onSuccess: (data) => {
        const userId = data.data.userID
        goToRoute(`/user/${userId}`)
      }
    })
  }
  ...

在前面的代码片段中,我们利用 mutate 回调函数来执行一些额外的副作用。以下是我们的操作:

  1. 我们使用 useMutation 创建我们的变异。

  2. 在这个变异内部,我们利用 onSuccess 回调,它将接收解析后的数据并向用户显示一个提示,告诉他们数据已被创建。

  3. 我们随后创建一个 submitForm 函数,该函数将在我们的代码中的某个 onSubmit 事件中被提供。

  4. 当被触发时,这个函数将阻止接收的事件传播。

  5. 这个函数将通过调用 mutate 来触发我们的变异。在这个 mutate 中,我们利用它的 onSuccess 回调来触发路由更改。

现在我们知道了如何使用 mutate 回调函数来执行一些额外的副作用,让我们看看如何在执行变异后重新触发查询。

如何在变异后重新触发查询的重新获取

当执行将改变你当前向用户显示的查询数据的变异时,建议你重新获取该查询。这是因为,在这个时候,你知道数据已经改变,但如果你的查询仍然被标记为内部新鲜,React Query 不会重新获取它;因此,你必须自己来做。

在阅读了前面的两个章节后,当你阅读这个标题时,你肯定会有所思考,那就是查询无效化!

下面是如何利用 onSuccess 回调来重新触发查询的重新获取:

  const queryClient = useQueryClient()
  const { data } = useQuery({
    queryKey: ["allUsers"],
    queryFn: fetchAllData,
  });
  const { mutate } = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ["allUsers"],
      })
    }
  });

在前面的代码片段中,我们利用 onSuccess 回调在变异成功后重新触发查询。以下是我们的操作:

  1. 我们可以访问 queryClient

  2. 我们使用 ["allUsers"] 作为查询键创建我们的查询。

  3. 我们创建我们的变异。在这个变异的 onSuccess 回调中,我们利用我们的 queryClientinvalidateQueries 方法来触发带有 ["allUsers"] 作为查询键的查询的重新获取。

如本节开头所述,这是一个推荐的做法,每次你正在变异用户在页面上看到的数据时,都应该这样做。现在,你可能正在想:如果我们的变异是成功的,它可能已经返回了新数据,所以我们不能只是手动更新我们的查询数据并避免额外的请求吗?

如何在变异后更新我们的查询数据

你当然可以手动更新你的查询数据。你所需要的是访问queryClient以及你想要更新的查询的查询键。

虽然这可能在用户端节省一些带宽,但它并不能保证你最终显示给用户的数据是准确的。如果其他人使用相同的应用程序更改了你的数据怎么办?

现在,如果有保证没有其他人能够更新这个服务器状态,那么请随意尝试。但请确保你的查询在某个地方重新获取,以确保所有数据都是最新的。

这是如何在成功变异后更新你的查询数据的方法:

 const queryClient = useQueryClient()
  const { data } = useQuery({
    queryKey: ["allUsers"],
    queryFn: fetchAllData,
  });
  const { mutate } = useMutation({
    mutationFn: createUser,
    onSuccess: (data) => {
      const user = data.data
      queryClient.setQueryData(["allUsers"], (prevData) =>
        [user, ...prevData]);
    }
  });

在之前的代码片段中,我们利用onSuccess回调来更新我们的查询数据,并避免重新获取它。以下是我们的操作:

  1. 我们获取对queryClient的访问权限。

  2. 我们使用["allUsers"]作为查询键创建我们的查询。

  3. 我们创建我们的变异。在这个变异的onSuccess回调中,我们利用queryClientsetQueryData函数来手动更新带有["allUsers"]作为查询键的查询数据。

  4. 在这次更新中,我们创建了一个新数组,该数组结合了我们创建的数据和之前的数据,以创建新的查询数据。

如你所见,你可以应用一些模式来改善在执行变异后的用户体验。现在,当经常提到变异时,每次都会出现一个话题,这个话题将结束本章:乐观更新!

执行乐观更新

正如我们在第二章中看到的,乐观更新是在一个正在进行的变异期间使用的一种模式,我们更新我们的 UI 以显示变异完成后将如何显示,尽管我们的变异尚未被确认完成。

好吧,React Query 允许你执行乐观更新,并且这使得它变得极其简单。你所需要做的就是使用我们在上一节中看到的回调函数。

这是如何使用useMutation钩子执行乐观更新的方法:

import axios from "axios";
import { useQuery, useMutation, useQueryClient } from
  "@tanstack/react-query";
import { useState } from "react";
const fetchAllData = async () => {
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/name-api`
  );
  return data;
};
const createUser = async (user) => {
  return axios.post
    (`https://danieljcafonso.builtwithdark.com/name-api`,
      user);
};
const Mutation = () => {
  const queryClient = useQueryClient();
  const [name, setName] = useState("");
  const [age, setAge] = useState(0);
  const { data } = useQuery({
    queryKey: ["allUsers"],
    queryFn: fetchAllData,
  });
  const mutation = useMutation({
    mutationFn: createUser,
    onMutate: async (user) => {
      await queryClient.cancelQueries({
        queryKey: ["allUsers"],
      });
      const previousUsers = queryClient.getQueryData({
        queryKey: ["allUsers"],
      });
      queryClient.setQueryData(["allUsers"], (prevData) =>
        [user, ...prevData]);
      return { previousUsers };
    },
    onError: (error, user, context) => {
      showToast("Something went wrong...")
      queryClient.setQueryData(["allUsers"], context.
        previousUsers);
    },
    onSettled: () =>
      queryClient.invalidateQueries({
        queryKey: ["allUsers"],
      }),
  });
  return (
    <div>
     {data?.map((user) => (
        <div key={user.userID}>
          Name: {user.name} Age: {user.age}
        </div>
      ))}
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <input
          name="number"
          type={"number"}
          onChange={(e) => setAge(Number(e.target.value))}
          value={age}
        />
        <button
          type="button"
          onClick={(e) => {
            e.preventDefault()
            mutation.mutate({ name, age })
          }}
        >
          Add
        </button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们将我们对变异的知识应用到实践中,以创建更好的用户体验。以下是我们的操作:

  1. 我们为我们的代码定义所需的导入。

  2. 创建fetchAllData查询函数。这个函数将触发一个GET请求到我们的端点以获取用户数据。

  3. 创建createUser变异函数。这个函数将接收用户并执行一个POST请求到我们的端点以创建它。

  4. 在我们的Mutation组件内部,我们执行以下操作:

    1. 我们获取对queryClient的访问权限。

    2. 创建姓名和年龄输入的状态变量及其相应的设置器。

    3. 使用["allUsers"]作为查询键和fetchAllData作为查询函数创建我们的查询。

    4. 使用createUser作为突变函数创建我们的突变。在这个突变内部,我们定义了一些回调:

      1. onMutate回调中,我们执行乐观更新:

        • 我们确保取消任何针对我们的查询(以["allUsers"]作为查询键)的正在进行中的查询。为此,我们使用我们的queryClient cancelQueries方法。

        • 我们将之前缓存的数据保存在["allUsers"]查询键下,以防需要回滚。为此,我们利用我们的queryClient getQueryData函数。

        • 我们通过合并我们的新数据与我们的旧数据,并更新["allUsers"]查询键下的缓存数据来执行乐观更新。为此,我们利用我们的queryClient setQueryData函数。

        • 如果需要回滚,我们返回我们的previousUsers数据。

      2. onError回调中,如果发生错误,我们需要回滚我们的数据:

        • 作为一种良好的实践,我们让我们的用户知道我们的突变操作出现了问题。在这种情况下,我们显示一个吐司通知。

        • 为了进行回滚,我们访问我们的上下文参数,并利用onMutate回调返回的previousUsers数据。然后我们使用这个变量来覆盖["allUsers"]查询键下的缓存数据。为此,我们使用我们的queryClient setQueryData函数。

      3. onSettled回调中,当我们的突变完成时,我们需要重新获取我们的数据:

        • 为了重新获取我们的数据,我们利用我们的queryClient invalidateQueries并使用["allUsers"]作为查询键来使查询无效。
      4. 在我们的组件返回中,我们创建一个div元素,如下所示:

    • 我们使用查询的data变量来显示用户的资料。

    • 我们使用我们的姓名和年龄输入创建受控表单。

    • 我们还创建了一个按钮,当按下时,会触发它的onClick事件,从而触发我们的带有姓名和年龄值的突变。

看过你如何构建乐观更新后,以下是我们的创建的乐观更新的流程:

  1. 我们的组件渲染,我们的查询获取我们的数据并将其缓存。

  2. 当我们点击添加按钮时,查询返回的数据会自动更新,包括新用户,并且立即在 UI 上反映这一变化。

  3. 如果发生错误,我们将回滚到之前的数据。

  4. 当我们的突变完成时,我们重新获取我们刚刚执行乐观更新的查询的数据,以确保我们的查询已更新。

拥有这些知识,你现在拥有了所有你需要用你的新盟友useMutation将突变游戏提升到下一个级别的知识!

摘要

在本章中,我们学习了 React Query 如何通过使用useMutation钩子来执行突变。到目前为止,你应该能够创建、删除或更新你的服务器状态。为了进行这些更改,你求助于突变函数,这个函数就像你的查询函数一样,支持任何客户端,并允许你使用 GraphQL 或 REST,只要它返回一个承诺即可。

你了解了一些useMutation钩子返回的内容,例如mutatemutateAsync函数。与useQuery类似,useMutation也返回突变dataerror变量,并为你提供一些你可以用来构建更好用户体验的状态。为了你的方便,useMutation还返回一个reset函数来清除你的状态,以及一个isPaused变量,以防你的突变进入暂停状态。

为了让你能够自定义开发者体验,你了解了一些常用的选项,这些选项允许你自定义useMutation钩子的体验。然后我们利用这四个选项中的四个来教你如何在突变运行后执行一些副作用。

最后,你使用了一些你学到的知识来执行乐观更新,并为你的应用程序用户提供更好的体验。

第七章 使用 Next.js 或 Remix 进行服务器端渲染中,我们将了解我们如何在即使我们使用服务器端框架的情况下利用 React Query。你将学习你如何在服务器上获取数据,并在客户端配置 React Query 以使其工作并构建更好的体验。

第七章:使用 Next.js 或 Remix 进行服务器端渲染

并非所有我们的应用程序都在客户端渲染。如今,使用利用 服务器端渲染SSR)的框架是很常见的。这些框架有助于提高应用程序的性能,并且它们的采用率每天都在增长。

现在,当使用这些框架时,大多数情况下,我们倾向于在服务器端执行数据获取或突变,这引发了一个问题:

我在使用 SSR 框架时还需要 React Query 吗?

在本章中,您将了解 React Query 如何与 initialDatahydrate 等框架相结合。

一旦您熟悉了这些模式,您将了解如何将它们应用到您的 Next.js 和 Remix 应用程序中。

在本章中,我们将涵盖以下主题:

  • 为什么我应该使用 React Query 与服务器端渲染框架一起使用?

  • 使用 initialData 模式

  • 使用 hydrate 模式

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_7

为什么我应该使用 React Query 与服务器端渲染框架一起使用?

SSR 已经被证明是网络开发者的好帮手。随着全栈框架如 Next.js 和最近出现的 Remix 的流行,React 生态系统已经发生了变化,导致新的模式被应用。

什么是服务器端渲染(SSR)?

SSR 是一个允许你在服务器上而不是在浏览器上渲染应用程序的过程。在这个过程中,服务器将渲染的页面发送到客户端。然后客户端通过一个称为“水合”的过程使页面完全交互式。

由于可以使用 SSR,可能值得做的事情之一是在服务器上获取数据。这有许多优点,但其中最好的是向用户提供已经加载了初始数据的页面。现在,仅仅因为你在服务器端加载数据,并不意味着你不需要在客户端获取数据的情况。如果你的页面包含客户端上频繁更新的数据,React Query 仍然是你最好的朋友。

但 React Query 是如何与 Next.js 或 Remix 等框架结合到我们的代码中的?我们会在服务器上获取数据,然后再在客户端获取吗?

简短的回答是不。如果我们那样做,我们只是在服务器上浪费内存,而没有利用 SSR 的优势。我们可以做的是在服务器端预取数据,并将其提供给 React Query,以便它在客户端管理。这样,当用户获取页面时,页面已经包含了用户所需的数据,从那时起,React Query 就会负责一切。

我们可以将两种模式应用于服务器上的预取数据,并将其发送到客户端的 React Query。它们如下:

  • initialData 模式

  • hydrate 模式

在下一节中,我们将学习如何利用initialData模式并将其应用到所提到的框架中:Next.js 和 Remix。

使用 initialData 模式

initialData模式是你可以在useQuery钩子中设置的选项。使用这个选项,你可以向useQuery提供它将用于初始化特定查询的数据。

这就是如何利用服务器端框架的最佳功能和 React Query 的initialData选项的过程:

  1. 你首先在服务器端预先获取你的数据并将其发送到你的组件。

  2. 在你的组件内部,你使用useQuery钩子渲染你的查询。

  3. 在这个钩子内部,你添加initialData选项,并将你在服务器端预先获取的数据传递给它。

现在我们来看看如何在 Next.js 中使用这个模式。

在 Next.js 中应用 initialData 模式

在下面的代码片段中,我们将使用 Next.js 的getServerSideProps在服务器上获取一些数据,然后利用initialData模式将数据传递给 React Query:

import axios from "axios";
import { useQuery } from "@tanstack/react-query";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function getServerSideProps() {
  const user = await fetchData({ queryKey: [{ username:
    "danieljcafonso" }] });
  return { props: { user } };
}
export default function InitialData (props) {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
    initialData: props.user,
  });
  return <div>This page is server side generated
    {data.hello}</div>;
}

在前面的代码片段中,我们将initialData模式应用到 Next.js 应用程序中。这里,我们有一个将服务器端生成的组件。这就是我们在这里所做的事情:

  1. 我们为这个组件做必要的导入。在这个场景中,是axios和我们的useQuery钩子。

  2. 我们创建我们的查询函数。在这个函数中,我们获取到我们的查询键,并从查询键中解构出用户名以执行我们的GET请求。然后我们返回我们的查询数据。

  3. 由于我们希望这个页面是服务器端渲染的,所以我们将其中的getServerSideProps函数包含在内。这个函数将在服务器端运行,在其中,我们调用我们的fetchData函数来获取我们的服务器状态数据并将其作为 props 返回,这些 props 将被发送到我们的InitialData组件。

  4. 在我们的InitialData组件中,我们获取到我们的props。在这些props中,我们可以访问从我们的getServerSideProps函数返回的数据。然后我们将这些数据传递给我们的创建的useQuery实例作为initialData选项。这意味着这个钩子在重新获取之前将具有我们在构建时获取的数据作为其初始数据。

现在你已经知道如何在 Next.js 中应用这个模式,让我们在 Remix 中做同样的事情。

在 Remix 中应用 initialData 模式

在下面的代码片段中,我们将使用 Remix 的loader在服务器上获取一些数据,然后利用initialData模式将数据传递给 React Query:

import axios from "axios";
import { useQuery } from "@tanstack/react-query";
import { useLoaderData } from "@remix-run/react";
import { json } from "@remix-run/node";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function loader() {
  const user = await fetchData({ queryKey: [{ username:
    "danieljcafonso" }] });
  return json({ user });
}
export default function InitialData() {
  const { user } = useLoaderData();
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
    initialData: user,
  });
  return <div>This page is server side rendered
    {data.hello}</div>;
}

在前面的代码片段中,我们将initialData模式应用到 Remix 应用程序中。这就是我们在这里所做的事情:

  1. 我们为这个组件做必要的导入。在这个场景中,是axios、我们的useQuery钩子、Remix 的useLoaderData钩子和一个json函数。

  2. 我们创建我们的查询函数。在这个函数中,我们获取到我们的查询键,并从查询键中解构出用户名以执行我们的GET请求。然后我们返回我们的查询数据。

  3. 我们接下来创建我们的 loader 函数。这是 Remix 用来允许你在服务器端加载将在你的组件中需要的数据的函数。在其内部,我们获取我们的数据,然后使用 json 函数发送一个带有 application/json 内容类型头和包含数据的 HTTP 响应。

  4. 在我们的 InitialData 组件中,我们利用 useLoaderData 来获取 loader 返回的数据。然后我们将这些数据传递给我们的 useQuery 实例作为 initialData 选项。这意味着这个钩子在重新获取之前将构建时获取的数据作为其初始数据。

到现在为止,你应该能够使用 initialData 模式。为了更有效地使用它,你需要注意以下几点:

  • 如果你在不同位置有相同查询的多个实例,你必须始终向它们传递 initialData。这意味着即使你在顶层和子组件中利用查询,你也必须通过 prop-drill 将 initialData 传递到需要数据的期望组件。

  • 由于你在服务器上获取数据并将其传递到你的 hook,React Query 将基于初始页面加载时而不是服务器上获取数据的时间来识别你的查询何时被渲染。

让我们看看当你使用支持服务器端渲染的框架与 React Query 结合时可以利用的第二种模式:hydrate 模式。

使用 hydrate 模式

使用 hydrate 模式,你可以使用之前预取的查询来脱水你的 QueryClient 并将其发送到客户端。在客户端,一旦页面加载并且 JavaScript 可用,React Query 将使用现有数据来 hydrate 你的 QueryClient。在此过程之后,React Query 也会确保你的查询是最新的。

这是如何利用 hydrate 模式结合你的服务器端框架和 React Query 的最佳实践的过程:

  1. 你首先要做的是创建一个 QueryClient 实例。

  2. 使用之前创建的 QueryClient 实例,你利用其 prefetchQuery 方法来预取给定查询键的数据。

  3. 你将你的 QueryClient 脱水并发送到客户端。

  4. 你的客户端接收脱水状态,将其 hydrate 并与正在使用的 QueryClient 合并。

  5. 在你的组件内部,你使用 useQuery 钩子以与你在 步骤 2 中添加的相同查询键渲染你的查询。你的查询将已经包含其数据。

在下一节中,我们将学习如何利用 hydrate 模式并将其应用于所提到的框架:Next.js 和 Remix。

在 Next.js 中应用 hydrate 模式

Next.js 使用 _app 组件来初始化所有页面,并允许你在页面变化之间保持一些共享状态或持久化布局。由于这个原因,我们可以利用它来用 Hydrate 包装所有我们的组件。Hydrate 包装器负责接收 dehydratedState 并将其 hydrate。

让我们看看如何应用这个包装器:

import { useState } from "react";
import {
  Hydrate,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
export default function App({ Component, pageProps }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />{" "}
      </Hydrate>
    </QueryClientProvider>
  );
}

在前面的代码片段中,我们执行以下操作:

  1. 我们进行所有必要的导入以设置我们的组件。在这个场景中,我们从 React 获取 useState 函数,并从 React Query 获取 HydrateQueryClientQueryClientProvider

  2. 在我们的 App 组件内部,我们执行以下操作:

    1. 我们首先创建一个新的 QueryClient 实例,并使用 useState 钩子将其分配为 state 变量。这是因为我们需要确保这些数据不会被我们应用程序的不同用户和请求共享。这将确保我们只创建一次 QueryClient

    2. 我们然后将我们的 queryClient 传递给 QueryClientProvider 以初始化它,并允许它被我们的 React Query 钩子访问。QueryClientProvider 还将包裹我们的 Component

    3. 最后,我们还用 Hydrate 包裹了我们的 Component。由于 Hydrate 需要接收 dehydratedState,无论它是否存在,我们从我们的 App 中获取 pageProps 并将其传递给我们的 Hydrate 状态属性。这意味着对于每个接收 dehydratedState 作为 props 的组件,这些 props 将被传递给我们的 Hydrate 包装器。

现在,我们已经准备好开始脱水数据。让我们看看我们如何做到这一点:

import axios from "axios";
import { dehydrate, QueryClient, useQuery } from
  "@tanstack/react-query";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function getServerSideProps() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(
    [{ queryIdentifier: "api", username: "danieljcafonso" }],
    fetchData
  );
  return { props: { dehydratedState: dehydrate(queryClient) } };
}
export default function SSR() {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
  });
  return <div>This page is server-side-rendered
    {data.hello}</div>;
}

在前面的代码片段中,我们预取了一些数据,这些数据将被 React Query 脱水和再水化。以下是我们的操作:

  1. 我们为此组件进行必要的导入。在这个场景中,它是 axios,并且从 React Query 方面,是 dehydrate 函数、QueryClientuseQuery 钩子。

  2. 我们创建我们的查询函数。在这个函数中,我们获取对查询键的访问权限,并从查询键中解构 username 以执行我们的 GET 请求。然后我们返回我们的查询数据。

  3. getServerSideProps 中,我们执行以下操作:

    1. 我们创建一个新的 QueryClient 实例。

    2. 然后,我们利用之前创建的实例来预取一个查询,该查询将在 [{ queryIdentifier: "api", username: "danieljcafonso" }] 查询键下缓存,并使用 fetchData 作为查询函数。

    3. 我们在 queryClient 上使用 dehydrate 并将其作为 props 返回,以便它可以在我们的 App 组件中被捕获。

  4. 在我们的 SSR 组件中,我们使用 [{ queryIdentifier: "api", username: "danieljcafonso" }] 作为查询键和 fetchData 作为查询函数创建了一个 useQuery 钩子。

由于我们从 getServerSideProps 函数中返回了 dehydratedState,这将作为 pageProps 传递并被 Hydrate 包装器捕获,包裹我们的组件。这意味着 React Query 将捕获我们的脱水状态,对其进行水化,并将这些新数据与 QueryClient 中的当前数据合并。这意味着当 SSR 内部的钩子第一次运行时,它将已经从 getServerSidePros 预取了数据。

现在你已经知道如何将此模式应用于 Next.js,让我们在 Remix 中这样做。

在 Remix 中应用 hydrate 模式

Remix 使用 root 组件来定义所有页面的根布局,并允许你在页面变化之间保持一些共享状态。这是通过使用 Outlet 组件来实现的。由于这个组件和根级别的 Outlet,我们可以利用它来用 Hydrate 包装所有我们的组件。

现在,与 Next.js 不同,没有方法可以访问 pageProps 来在根级别访问 dehydratedState。因此,我们需要安装一个名为 use-dehydrated-state 的第三方包。

以下是向你的项目添加 use-dehydrated-state 的方法:

  • 如果你正在你的项目中运行 npm,请运行以下命令:

    npm i use-dehydrated-state
    
  • 如果你正在使用 Yarn,请运行以下命令:

    yarn add use-dehydrated-state
    
  • 如果你正在使用 pnpm,请运行以下命令:

    pnpm add use-dehydrated-state
    

use-dehydrated-state 允许我们在根级组件中访问我们的脱水状态。

现在,我们可以进行必要的设置以利用 HydrateQueryClientProvider 包装器:

import {
  ...
  Outlet,
} from "@remix-run/react";
import { useState } from "react";
import {
  Hydrate,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import { useDehydratedState } from "use-dehydrated-state";
export default function App() {
  const [queryClient] = useState(() => new QueryClient());
  const dehydratedState = useDehydratedState();
  return (
    ...
       <QueryClientProvider client={queryClient}>
         <Hydrate state={dehydratedState}>
           <Outlet />
         </Hydrate>
       </QueryClientProvider>
     ...
  );
}

在前面的代码片段中,我们执行以下操作:

  1. 我们进行所有必要的导入以设置我们的组件。在这个场景中,我们得到以下内容:

    1. Remix 的 Outlet

    2. 来自 React 的 useState 函数

    3. 来自 React Query 的 HydrateQueryClientQueryClientProvider

    4. 来自 use-dehydrated-stateuseDehydratedState 钩子

  2. 在我们的 App 组件内部,我们执行以下操作:

    1. 我们首先创建一个新的 QueryClient 实例,并使用 useState 钩子将其分配为 state 变量。这是因为我们需要确保这些数据不会被我们的应用程序的不同用户和请求共享。这将确保我们只创建一次 QueryClient

    2. 然后,我们将 queryClient 传递给 QueryClientProvider 以启动它,并允许它被我们的 React Query 钩子访问。QueryClientProvider 还会包装由 Outlet 渲染的组件。

    3. 最后,我们还将 OutletHydrate 包装起来。由于 Hydrate 需要在从服务器接收到 dehydratedState 时接收它,我们从 useDehydratedState 钩子中获取它。这意味着对于从其 loader 接收 dehydratedState 的每个组件,这些数据将被传递给我们的 Hydrate 包装器。

现在,我们已经准备好开始脱水数据。让我们看看如何操作:

import axios from "axios";
import { dehydrate, QueryClient, useQuery } from "@tanstack/react-query";
import { json } from "@remix-run/node";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  const { data } = await axios.get(
    `https://danieljcafonso.builtwithdark.com/
      react-query-api/${username}`
  );
  return data;
};
export async function loader() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(
    [{ queryIdentifier: "api", username: "danieljcafonso" }],
    fetchData
  );
  return json({ dehydratedState: dehydrate(queryClient) });
}
export default function Index() {
  const { data } = useQuery({
    queryKey: [{ queryIdentifier: "api", username:
      "danieljcafonso" }],
    queryFn: fetchData,
  });
  return <div>This page is server side rendered
    {data.hello}</div>;
}

在前面的代码片段中,我们正在预取一些数据,这些数据将被 React Query 脱水和再水化。以下是我们的操作:

  1. 我们为这个组件进行必要的导入。在这个场景中,它们如下所示:

    1. axios 客户端

    2. 来自 React Query 的 dehydrate 函数、QueryClientuseQuery 钩子

    3. Remix 的 json 函数

  2. 我们创建我们的查询函数。在这个函数中,我们获取查询键的访问权限,并从查询键中解构用户名以执行我们的 GET 请求。然后我们返回我们的查询数据。

  3. loader 中,我们执行以下操作:

    1. 我们创建一个新的 QueryClient 实例。

    2. 我们随后利用先前创建的实例来预取一个查询,该查询将在[{ queryIdentifier: "api", username: "danieljcafonso" }]查询键下缓存,并使用fetchData作为查询函数。

    3. 然后,我们使用dehydratequeryClient进行操作,并将其作为HTTP响应返回。

  4. 在我们的Index组件中,我们使用[{ queryIdentifier: "api", username: "danieljcafonso" }]作为查询键,并使用fetchData作为查询函数创建一个useQuery钩子。

由于我们从loader函数中返回了dehydratedState,这将由useDehydratedState捕获并传递给我们的Hydrate包装器,包裹我们的组件。这意味着 React Query 将捕获dehydratedState,对其进行解冻,并将这些新数据与QueryClient中当前的数据合并。由于这个过程,当Index内部的钩子第一次运行时,它已经拥有了从loader中预取的数据。

摘要

本章教会了我们如何使用 React Query 补充我们的服务器端渲染应用程序。

你学习了如何使用 React Query 在服务器端预取数据并将其发送到客户端的 React Query。为此,你需要了解两种模式,initialDatahydrate。在initialData模式中,你在服务器端预取数据并将其传递到客户端useQuery钩子的initialData选项中。在hydrate模式中,你在服务器端预取查询,解冻查询缓存,并在客户端进行解冻。

第八章 测试 React Query 钩子和组件中,我们将关注帮助你夜晚睡得更好的事情之一:测试。你将了解如何测试你的组件,即使用 React Query,以及一些自定义钩子来提高你的开发者体验。

第八章:测试 React Query 钩子和组件

你几乎已经掌握了 React Query!到目前为止,你已经非常清楚查询和突变是如何工作的,并且准备好在服务器端渲染的项目中利用 React Query。现在,我们将探讨你需要成为真正的 React Query 英雄的最后一种技能——使用代码测试 React Query。

本章将教你如何使用组件和钩子测试useQueryuseMutation。但在那之前,你将了解一个非常有用的库,它可以帮助你测试 React Query 代码,称为 Mock Service Worker。

然后,你将学习一些重构技巧和窍门,你可以利用它们使你的 React Query 代码更易于阅读和重用。

在掌握了这些知识之后,你就可以开始测试你的代码了。你将从测试利用 React Query 的组件开始,看看从以用户为中心的角度进行查询和突变测试是什么样的。

最后,我们将深入了解实现细节,看看我们应该何时以及如何测试使用 React Query 的钩子。

本章我们将涵盖以下主题:

  • 配置 Mock Service Worker

  • 代码组织

  • 测试使用 React Query 的组件

  • 测试使用 React Query 的自定义钩子

技术要求

本章的所有代码示例都可以在 GitHub 上找到,地址为github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_8

配置 Mock Service Worker

在测试 React 应用程序时,人们经常问的一个问题是如何测试 API 调用。这个问题通常会导致一个后续问题:“我如何确保我的网络请求返回我期望的数据,以便我的测试总是接收到相同的数据,不会变得不可靠?”有许多方法可以回答这些问题,我们可以遵循许多实现。最常用的实现通常是模拟你的数据获取客户端。

虽然这种方法可行,但我在我所参与的所有采用这种方法的项目中经常看到的一个问题是:你写的测试越多,它们就越难以维护。这是因为模拟像fetchaxios这样的东西需要大量的样板代码来处理不同路由被击中、同一路由的不同响应以及清理客户端模拟以避免测试相互泄漏等问题。我们不要忘记,如果我们在一个应用程序中使用 GraphQL 和 REST,我们必须根据你正在测试的组件模拟额外的客户端。

如果我告诉你有一个可以用来拦截你的网络请求并返回预定义数据而无需模拟任何客户端的替代方案,你会怎么想?如果我说这个替代方案支持 REST 和 GraphQL,你会怎么想?如果我说这个替代方案还可以用于你的应用程序,为你的后端团队尚未实现的某个路由提供一些模拟数据,你会怎么想?你可以用 Mock Service Worker (MSW) 做到所有这些。

如 MSW 文档所述:“Mock Service Worker 是一个使用 Service Worker API 来拦截实际 请求 的 API 模拟库” (mswjs.io/docs/)。

MSW 利用服务工作者在网络级别拦截请求,并为该特定请求返回一些预定义数据。这意味着,只要有一个定义好的 API 合同,你就可以在端点存在之前返回模拟数据。此外,利用这些预定义数据在你的测试中意味着你不再需要模拟 axiosfetch。重要的是要提到,服务工作者仅在浏览器中工作。在你的测试中,MSW 使用请求拦截器库,允许你重用你在浏览器中已有的相同模拟定义。

虽然 MSW 在浏览器中使用非常有帮助,但它超出了本章的范围。在本章中,我们只会使用 MSW 在我们的测试中。

这是将 MSW 添加到你的项目的方法:

  • 如果你正在你的项目中运行 npm,请运行以下命令:

    npm install msw --save-dev
    
  • 如果你使用的是 Yarn,请运行以下命令:

    yarn add msw --dev
    
  • 如果你使用的是 pnpm,请运行以下命令:

    pnpm add msw --save-dev
    

一旦 MSW 安装完成,我们必须创建我们的请求处理器和响应解析器。

请求处理器允许你在处理请求时指定方法、路径和响应。它们通常与响应解析器配对。响应解析器是一个传递给请求处理器的函数,它允许你在拦截请求时指定模拟的响应。

让我们现在创建一些处理器来处理一些路由。以下是我们要做的事情。

src/mocks 文件夹中,创建一个 handlers.js 文件。

handlers.js 文件中,添加以下代码:

import { rest } from "msw";
export const handlers = [
  rest.get("*/api/*", (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        data: "value"
      })
    );
  }),
];

在前面的代码片段中,我们做了以下操作:

  1. 我们导入包含一组请求处理器的 rest 命名空间,用于处理 REST 请求。

  2. 我们创建一个 handlers 数组,它将包含我们所有的请求处理器。

我们创建的第一个模拟是一个针对包含 /api/ 的任何路由的 GET 请求。

当请求击中这个请求处理器时,它将返回一个响应,该响应将返回一个包含 "value" 字符串的 200 OK 响应代码的对象。

现在我们已经创建了我们的 handlers,我们需要确保 MSW 将使用我们之前创建的 handlers 来拦截我们的请求。

这是我们需要做的事情。

src/mocks 文件夹中,创建一个 server.js 文件。

server.js 文件中,添加以下代码:

import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);

在前面的片段中,我们利用 setupServer 函数和我们的创建的 handlers 数组来创建一个对象,该对象负责拦截我们的请求并使用我们提供的 handlers

现在我们已经创建了我们的服务器文件,我们需要确保 Jest 使用它们。为此,在我们的 setupTests.js 文件中,添加以下代码:

import { server } from "./mocks/server.js";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

这就是我们前面片段中所做的:

  1. 我们导入我们创建的 server 对象。

  2. 我们利用 beforeAll 全局钩子来确保 MSW 在我们的任何测试执行之前拦截我们的请求。

  3. 我们随后利用 afterEach 全局钩子,确保在每次测试之后重置我们的处理程序。这考虑了一种场景,即我们为我们的某个测试添加一个自定义处理程序,以防止它们泄漏到另一个测试中。

  4. 最后,我们利用 afterAll 全局钩子,以确保在我们所有的测试运行之后,我们清理并停止拦截请求。

现在,我们的测试所做的任何 API 请求都将被 MSW 拦截。

在看到我们如何使用挂钩测试我们的组件和 React Query 之前,让我们看看我们可以应用的一些模式,以使我们的代码更加结构化和易于测试。

组织代码

你可以以许多方式组织你的代码。现在,我们需要注意的一件事是选择可以节省你时间并使你的代码在长期内更好的模式。本节将讨论三种我们可以共同或独立利用的模式,以使我们的代码更加结构化、可读和组织。以下是本节我们将讨论的内容:

  • 创建一个 API 文件

  • 利用查询键工厂

  • 创建一个 hooks 文件夹

创建一个 API 文件

创建一个 API 文件来包含我对特定域的所有请求,这是我遵循的模式。

在这个文件中,我利用我的 API 客户端创建负责向给定路由发送请求并返回请求数据的函数。

这特别有用,因为它避免了在代码中重复相同的请求逻辑,并将所有特定域的请求集中在同一个文件中。

对于本书范围内所做的所有请求,我更愿意为我的用户域创建一个文件,因为范围似乎集中在用户上。所以,在我们的 api 文件夹中,我们将创建一个 userAPI.js 文件。

图 8.1 – 将 userAPI.js 添加到我们的 API 文件夹

图 8.1 – 将 userAPI.js 添加到我们的 API 文件夹

在那个文件中,我们现在可以将所有请求移动到我们的代码中。这可能看起来是这样的:

import axios from "axios";
export const axiosInstance = axios.create({
  baseURL: "https://danieljcafonso.builtwithdark.com",
});
export const getUser = async (username, signal) => {
  const { data } = await axiosInstance.get
    (`/react-query-api/${username}`, {
    signal,
  });
  return data;
};
export const createUser = async (user) => {
  return axiosInstance.post(`/name-api`, user);
};

在前面的片段中,我们可以看到一个 userAPI 文件的例子,其中包含我们的 axios 客户端实例、一个 getUser 函数(用于从给定用户获取数据)和一个 createUser 函数(用于创建用户)。

如您所见,这种模式提高了最终使用我们 API 文件中函数的组件的代码可重用性和可读性。

你可以做的另一件事是我们之前片段中没有做的,那就是添加来自你的查询函数的特定逻辑。如果你只使用 React Query,这将使这些函数在你的应用程序中更容易访问。我更喜欢将我的查询函数和这些 API 函数分开,因为我经常使用不同的查询函数与相同的 API 函数。不过,如果你选择使用它,这也会提高你的代码可读性。

利用查询键工厂

管理查询键通常是一件麻烦事。我们忘记了我们已经使用了哪些,需要浏览我们的大部分查询来记住它们。这就是查询键工厂大放异彩的地方。

查询键工厂可以是一个包含函数的对象,每个属性中都有一个负责生成查询键的函数。这样,你就可以将所有的查询键放在同一个地方,并停止浪费时间试图记住它们。

这就是你的查询键工厂可能的样子:

export const userKeys = {
    all: () => ["allUsers"],
    api: () => [{queryIdentifier: "api"}],
    withUsername: (username = "username") =>
      [{ ...userKeys.api[0], username }],
    paginated: (page) => [{ ...userKeys.api, page }]
}

如前文片段所示,我们创建了一个userKey对象,它将成为我们的查询键工厂。在每一个属性中,我们都有一个负责返回我们的查询键的函数。

创建一个钩子文件夹

这里的名字也足以说明一切。我喜欢的代码组织建议之一是创建一个钩子文件夹。

我喜欢在这个文件夹中创建自定义钩子,其中包含一些我经常重复的查询和突变,或者那些最终包含太多逻辑并影响我的代码可读性的钩子。这使得我可以更容易地在隔离状态下测试特定的钩子,并使使用它们的组件更具可读性。

例如,还记得我们在第六章中执行乐观更新吗?我们创建的useMutation钩子是一个很好的候选者,可以移动到自定义钩子。我将创建一个useOptimisticUpdateUserCreation自定义钩子,并将我的代码移到那里。这个钩子看起来是这样的:

import { useMutation, useQueryClient } from
  "@tanstack/react-query";
import { userKeys } from "../utils/queryKeyFactories";
import { createUser } from "../api/userAPI";
const useOptimisticUpdateUserCreation = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: createUser,
    retry: 0,
    onSettled: () => queryClient.invalidateQueries
      (userKeys.all()),
    onMutate: async (user) => {
      await queryClient.cancelQueries(userKeys.all());
      const previousUsers = queryClient.getQueryData
        (userKeys.all());
      queryClient.setQueryData(userKeys.all(), (prevData)
        => [
        user,
        ...prevData,
      ]);
      return { previousUsers };
    },
    onError: (error, user, context) => {
      queryClient.setQueryData(userKeys.all(),
        context.previousUsers);
    },
  });
};
export default useOptimisticUpdateUserCreation;

在前文片段中,我们创建了useOptimisticUpdateUserCreation钩子,并将OptimisticMutation组件中的代码移到那里。从代码中,你也可以看到,我们已经应用了我们的 API 文件和查询工厂模式。

在使用我们的钩子的组件中,我们现在只需要导入钩子并像这样使用它:

const mutation = useOptimisticUpdateUserCreation();

应用本节的所有模式,你的项目结构最终可能看起来是这样的:

图 8.2 – 遵循这三个模式后项目结构可能的样子

图 8.2 – 遵循这三个模式后项目结构可能的样子

现在我们已经看到了这些模式,让我们最终开始测试我们的代码。我们将从一个最推荐的方法开始——使用 React Query 钩子测试组件。

测试使用 React Query 的组件

当 React Testing Library 首次推出时,它遵循一个主要指导原则,这个原则改变了我们编写测试的方式。这个指导原则是,“你的测试越接近你的软件的使用方式,它们就能给你带来越多的信心” (testing-library.com/docs/guiding-principles/)。

从那个点开始,我们的测试中发生了许多变化。专注于以用户为中心的方法意味着不惜一切代价避免在我们的测试中包含实现细节。这意味着不再有浅渲染,不再有状态和属性引用,以及更以用户为中心的查询 DOM 的方式。

阅读最后一部分,你可能想知道如何采用以用户为中心的方法来测试你的组件。嗯,答案很简单——用户不需要知道他们正在使用的页面是否使用了 React Query。如果你像使用页面一样编写测试,这意味着你可能会意外地发现用户可能会遇到的问题,并且如果由于某种原因你更改了实现,你的测试不会中断。

在某些场景中,你可能需要将你的测试与某些实现细节绑定,以帮助你进行断言,但我们将不惜一切代价避免在本节中这样做。

在我们开始编写测试之前,我们需要做一些设置。

设置测试工具

当测试利用 React Query 的组件时,我们必须确保我们用 QueryClientProvider 包裹这些组件。现在,我们可以在每个测试中创建一个自定义包装器,并在渲染时用它来包裹我们的组件,但请记住,你很可能会得到许多以某种方式使用 React Query 的组件。

这就是设置一些测试工具可以帮助你的地方。我非常喜欢遵循的一个模式是覆盖测试库中的 render 函数,并使用这个函数自动包裹渲染的每个组件,使用我们的 React Query QueryClientProvider。为此,我在 utils 文件夹中创建了一个 test-utils.js 文件。

这是我们可以在 test-utils.js 文件中添加的内容:

import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from
  "@tanstack/react-query";
const customRender = (ui, { ...options } = {}) => {
  const queryClient = new QueryClient({
    logger: {
      log: console.log,
      warn: console.warn,
      error: () => {},
    },
    defaultOptions: {
      queries: {
        retry: 0,
        cacheTime: Infinity,
      },
    },
  });
  const CombinedProviders = ({ children }) => {
    return (
      <QueryClientProvider client={queryClient}>
        {children}</QueryClientProvider>
    );
  };
  return render(ui, { wrapper: CombinedProviders,
     ...options });
};
export * from "@testing-library/react";
export { customRender as render };

这是我们前面片段中做的事情:

  1. 我们从 React Testing Library 导入 render 函数。

  2. 我们从 React Query 导入我们的 QueryClientQueryClientProvider

  3. 我们创建了一个自定义的 render 函数 (customRender):

    1. 这个函数将接收一个 ui 参数,它将是我们要渲染的组件。它还将接收一个 options 对象,我们可以将其转发给 render 函数。

    2. 我们创建我们的 queryClient 实例。在这里,我们覆盖了我们的 loggererror 属性,以避免显示来自 React Query 的错误。这是因为我们可能想要测试错误场景,并且我们不希望 React Query 用我们预期的错误污染我们的 console。我们还定义我们的查询,在查询失败后永远不尝试重试查询,并将我们的 cacheTime 设置为 Infinity 以避免在手动设置 cacheTime 值的场景中产生 Jest 错误信息。

    3. 我们创建一个 CombinedProviders 包装器,它将负责用 QueryClientProvider 包裹我们的组件。

    4. 我们调用 React Testing Library 的 render 函数,传递给它 ui 参数,并用我们的 CombinedProviders 包裹它,然后发送我们接收到的 options

  4. 我们导出所有的 React Testing Library 和我们的 customRender 函数,这将是现在的主 render 函数。这意味着我们现在在测试中导入这个文件而不是 React Testing Library。

注意在代码片段中,我们是在 customRender 函数内部创建我们的 queryClient 而不是外部。如果您想避免在测试之间清理查询缓存,可以采用这种方法。如果您想在测试之间使用相同的 QueryClient,可以在函数外部创建 queryClient 实例。

现在既然我们的 render 函数已经准备好使用组件渲染 React Query,我们可以开始编写测试了。

测试查询

在以下小节中,我们将看到一些在您日常使用 React Query 时可能会遇到的一些常见测试场景。

检查数据是否已获取

我们必须编写的最常见的测试之一是确保我们的数据已被正确获取。让我们从这个场景开始,并重新审视我们来自 第五章 的并行查询示例。我们还将重写代码以适应本章中提到的一些实践。让我们先看看我们的 ParallelQueries 组件:

export const ParallelQueries = () => {
  const { multipleQueries } = useMultipleQueriesV2();
  return (
    <div>
      {multipleQueries.map(({ data, isFetching }, index) => (
        <p key={index}>{isFetching ? "Fetching data..." :
          data.hello}</p>
      ))}
    </div>
  );
};

如您从前面的代码片段中看到的,代码基本上与第 5 章 中展示的相同,除了我们获取数据的那部分。在这里,我们应用了本章中提到的模式之一,并将这个逻辑移动到自定义钩子文件夹内的自定义钩子中。

让我们现在看看 useMultipleQueriesV2 钩子文件内部的内容:

import { useQueries } from "@tanstack/react-query";
import { userKeys } from "../utils/queryKeyFactories";
import { getUser } from "../api/userAPI";
const fetchData = async ({ queryKey }) => {
  const { username } = queryKey[0];
  return await getUser(username);
};
const usernameList = ["userOne", "userTwo", "userThree"];
const useMultipleQueriesV2 = () => {
  const multipleQueries = useQueries({
    queries: usernameList.map((username) => {
      return {
        queryKey: userKeys.withUsername(username),
        queryFn: fetchData,
      };
    }),
  });
  return { multipleQueries }
};
export default useMultipleQueriesV2

如您从前面的代码片段中看到的,我们基本上只是将组件中的内容移动到我们的 useMultipleQueriesV2 钩子中。注意,我们还利用了本章中提到的其他两个模式:

  • 我们在 userKeys 工厂中创建一个条目,并利用它来设置我们的 useQueries 钩子,queryKey

  • 我们创建一个 API 文件来收集我们的用户 API 函数,并添加我们的 getUser 函数

这就是我们的 getUser 函数的样子:

export const getUser = async (username, signal) => {
  const { data } = await axiosInstance.get
    (`/react-query-api/${username}`, {
    signal,
  });
  return data;
};

在此代码片段中显示的getUser函数负责对我们的给定端点发起GET请求,并在我们的signal告诉axios这样做时取消该请求。

现在你已经重新熟悉了这个组件及其工作方式,让我们开始测试它。

在我们编写测试之前,首先需要确保 MSW 正在拦截GET请求并返回我们想要的数据:

  rest.get("*/react-query-api/*", (req, res, ctx) => {
    return res(
      ctx.delay(500),
      ctx.status(200),
      ctx.json({
        hello: req.params[1],
      })
    );
  })

在前面的代码片段中,我们创建了一个请求处理器并将其添加到我们的handlers数组中,该处理器执行以下操作。

每当我们拦截到包含/react-query-api/路径的端点的GET请求时,我们返回一个将被延迟 500 毫秒的200 OK响应,其体中将包含一个具有hello属性的对象,该属性将包含请求参数的第二位参数。

这意味着对danieljcafonso.builtwithdark.com/react-query-api/userOne端点的GET请求将返回一个包含以下对象的200 OK响应:

{
  hello: "Hello userOne"
}

现在我们确信我们的组件在请求后总是会接收到相同的数据,我们可以编写我们的测试。

现在,我建议你从一个用户的角度来看ParallelQueries组件,并考虑你可能想要测试的场景。这里的经验法则是思考,“如果我是与这段代码交互的用户,我会与什么交互或期望发生什么?”

根据前面的分析,我想出了两个测试场景:

  • userOneuserTwouserThree

  • 为我们每个请求显示"Fetching data…"消息。

考虑到这些场景,我们可以编写我们的测试。让我们看看我们的测试文件会是什么样子:

import { ParallelQueries } from "../MultipleQueries";
import { render, screen } from "../utils/test-utils";
describe("Parallel Queries Tests", () => {
  test("component should fetch and render multiple data",
    async () => {
    render(<ParallelQueries />);
    const text = await screen.findByText("userOne");
    expect(text).toBeInTheDocument();
    expect(screen.getByText("userTwo")).toBeInTheDocument();
    expect(screen.getByText("userThree")).toBeInTheDocument();
  });
  test("component should show loading indicator for each
    query", () => {
    render(<ParallelQueries />);
    const isFetching = screen.getAllByText("Fetching data...");
    expect(isFetching).toHaveLength(3);
  });
});

让我们现在回顾一下前面代码片段中我们做了什么:

  1. 我们导入我们的ParallelQueries组件,以及从我们的test-utils中的自定义render函数和screen对象。

  2. 我们创建我们的测试套件,并在其中创建我们的测试:

    1. 对于"component should fetch and render multiple data"测试,我们执行以下操作:

      1. 渲染我们的ParallelQueries组件。

      2. 由于我们需要等待数据被获取,我们利用 React Testing Library 中的async查询变体(findBy)和await,直到userOne文本出现在 DOM 上。

      3. 一旦我们的查询找到userOne文本,我们断言它在 DOM 中,并对userTwouserThree重复相同的断言。在这些最后两个例子(userTwouserThree)中,我们不需要利用findBy变体,因为数据已经存在于 DOM 上,所以我们使用getBy变体。

    2. 对于"component should show loading indicator for each query"测试,我们执行以下操作:

      1. 渲染我们的ParallelQueries组件。

      2. 由于我们在模拟响应中添加了 500 毫秒的延迟,我们的数据不会立即可用以进行渲染,因此我们应该显示加载指示器。由于我们将有多个指示器,我们利用getAllBy变体来获取与我们的查询匹配的元素数组。

      3. 我们随后断言我们的元素数组长度为3,以确保每个查询都有一个"Fetching data…"消息。

通过这些测试,我们遵循了一种反映我们与组件交互时用户行为的方法,同时也在我们的ParallelQueries组件和useMultipleQueriesV2自定义钩子上实现了 100%的覆盖率。

在大多数情况下,为了处理数据获取场景,你只需要等待你获取的数据在 DOM 上被渲染。只有一个查询?等待数据在 DOM 上显示。有多个并行查询?等待数据在 DOM 上显示。有依赖查询?等待第一个查询的数据在 DOM 上显示。然后,为后续查询重复此步骤。

现在,在某些场景中,您将不得不执行一些操作以到达您的测试断言。其中一些场景甚至可能涉及查询无效化或查询取消。由于这些场景的相似性,让我们现在看看我们可以使用查询无效化进行哪些测试。

检查查询是否被无效化

正如您应该从第五章中记住的,查询无效化是指您手动标记您的查询为过时,以便 React Query 可以在渲染时重新获取它。

让我们回顾一下在第五章中看到的QueryInvalidation组件:

const fetchData = async ({ queryKey}) => {
  const { username } = queryKey[0];
  return await getUser(username);
};
const QueryInvalidation = () => {
  const { data, isFetching } = useQuery({
    queryKey: userKeys.withUsername("userOne"),
    queryFn: fetchData,
  });
  const queryClient = useQueryClient();
  return (
    <div>
      <p>{isFetching ? "Loading..." : data?.hello}</p>
      <button onClick={() => queryClient.invalidateQueries
        (userKeys.api())}>
        Invalidate Query
      </button>
    </div>
  );
};

如您从前面的代码片段中看到的,代码仍然非常类似于第五章中的代码。我们在这里所做的唯一改变是应用 API 文件模式,并利用本章之前看到的getUser函数,以及将我们的查询键更改为利用查询键工厂模式。

现在您已经重新熟悉了这个组件及其工作方式,让我们开始对其进行测试。

由于我们正在利用getUser函数,我们不需要在 MSW 中创建一个新的请求处理器,因为我们正在使用相同的端点。

现在,从以用户为中心的角度来看QueryInvalidation组件,以下是您可能识别出的三个测试场景:

  • userOne

  • "``Loading…"消息。

  • 作为用户,我希望点击无效查询按钮时重新获取我的查询:在这种情况下,我们希望我们的组件被渲染,并等待它渲染一个问候消息,点击无效查询按钮,等待问候消息消失,等待加载指示器消失,并等待问候消息再次出现。这样,我们就能确保我们的查询已被无效化。

考虑到这些场景,我们可以为我们的QueryInvalidation组件编写测试。让我们看看我们的测试文件会是什么样子:

import { QueryInvalidation } from "../QueryClientExamples";
import { fireEvent, render, screen, waitFor } from "../utils/test-utils";
describe("QueryInvalidation Tests", () => {
  test("component should display fetched data", async () => {
    render(<QueryInvalidation />);
    const text = await screen.findByText("userOne");
    expect(text).toBeInTheDocument();
  });
  test("component should show a loading indicator", () => {
    render(<QueryInvalidation />);
    expect(screen.getByText("Loading...")).toBeInTheDocument();
  });
  test("component should invalidate query", async () => {
    render(<QueryInvalidation />);
    const text = await screen.findByText("userOne");
    expect(text).toBeInTheDocument();
    const invalidateButton = screen.getByRole("button", {
      text: "Invalidate Query",
    });
    fireEvent.click(invalidateButton);
    await waitFor(() =>
      expect(screen.queryByText("userOne")).not.
        toBeInTheDocument()
    );
    await waitFor(() =>
      expect(screen.queryByText("Loading"…")).not.
        toBeInTheDocument()
    );
    expect(screen.getByText("userOne")).
      toBeInTheDocument();
  });
});

现在,让我们回顾一下前面代码片段中我们在做什么:

  1. 我们导入我们的QueryInvalidation组件,并从我们的test-utils中导入我们的自定义render函数、screen对象、fireEvent实用工具和waitFor函数。

  2. 我们创建我们的测试套件,并在其中编写我们的测试:

    1. 对于"component should display fetched data"测试,我们做以下操作:

      1. 渲染我们的QueryInvalidation组件。

      2. 由于我们需要等待数据被获取,我们利用 React Testing Library 中的async查询变体(findBy)和await,直到userOne文本出现在 DOM 上。

      3. 一旦我们的查询找到userOne文本,我们断言它出现在 DOM 中。

    2. 对于"component should show a loading indicator"测试,我们做以下操作:

      1. 渲染我们的QueryInvalidation组件。

      2. 由于我们添加了 500 毫秒的延迟到模拟响应中,我们的数据不会立即可用以进行渲染,因此我们应该看到加载指示器出现。然后我们利用getBy查询变体来帮助断言"Loading…"文本出现在 DOM 中。

    3. 对于"component should invalidate query"测试,我们做以下操作:

      1. 渲染我们的QueryInvalidation组件。

      2. 我们等待数据被获取,并相应地断言它出现在 DOM 上。

      3. 我们找到了我们的getByRole查询,这将帮助我们找到带有Invalidate Query文本的按钮。

      4. 然后,我们利用fireEvent实用工具在按钮上触发一个click事件。

      5. 然后,我们利用waitFor函数等待断言评估为true。在这种情况下,我们等待我们的查询数据从 DOM 中消失。

      6. 然后,我们再次利用waitFor函数,这次是为了等待加载指示器从 DOM 中消失。

      7. 最后,我们通过检查数据是否再次出现在 DOM 上来断言我们的查询已完成重新获取。

现在,我们已经检查了如何测试查询无效化。你可能想知道查询取消与查询无效化有何不同。最终,测试查询取消会在以下方面有所不同:

  • 我们的查询函数需要接收AbortController信号并将其转发到我们的getUser函数。

  • 与从queryClient调用invalidateQuery函数不同,我们调用cancelQueries

  • 在我们的测试中,前两个场景完全相同。在第三个场景中,我们在渲染组件后立即点击取消按钮。完成此操作后,DOM 不应显示数据或加载指示器。

现在你已经知道了如何以用户为中心的方法测试大多数场景,让我们将这个知识付诸实践,看看我们如何测试一个分页场景。

测试分页查询

第五章 中,我们学习了 useQuery 如何允许我们创建分页查询,并随后用它来构建分页 UI 组件。

让我们回顾一下在 第五章 中看到的 PaginatedQuery 组件:

import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getPaginatedData } from "./api/userAPI";
import { userKeys } from "./utils/queryKeyFactories";
const fetchData = async ({ queryKey }) => {
  const { page } = queryKey[0];
  return await getPaginatedData(page);
};
const PaginatedQuery = () => {
  const [page, setPage] = useState(0);
  const { isLoading, isError, error, data, isFetching,
    isPreviousData } =
    useQuery({
      queryKey: userKeys.paginated(page),
      queryFn: fetchData,
      keepPreviousData: true,
    });
  if (isLoading) {
    return <h2>Loading initial data...</h2>;
  }
  if (isError) {
    return <h2>{error.message}</h2>;
  }
  return (
    <>
      <div>
        {data.results.map((user) => (
          <div key={user.email}>
            {user.name.first}
            {user.name.last}
          </div>
        ))}
      </div>
      <div>
        <button
          onClick={() => setPage((oldValue) =>
            Math.max(oldValue - 1, 0))}
          disabled={page === 0}
        >
          Previous Page
        </button>
        <button
          disabled={isPreviousData}
          onClick={() => setPage((old) => old + 1)}
        >
          Next Page
        </button>
      </div>
      {isFetching ? <span> Loading...</span> : null}
    </>
  );
};
export default PaginatedQuery;

如前文片段所示,它与我们在 第五章 中看到的内容几乎相同。注意,我们还利用了本章中提到的两种模式:

  • 我们在 userKeys 工厂中创建了一个条目,并利用它来设置我们的 useQuery 钩子,queryKey

  • 我们创建了一个 API 文件来收集我们的用户 API 函数,并添加了我们的 getPaginatedData 函数

这就是我们的 getPaginatedData 函数的样子:

export const getPaginatedData = async (page) => {
  const { data } = await axiosInstance.get(
    `/react-query-paginated?page=${page}&results=10`
  );
  return data;
};

前文片段中显示的 getPaginatedData 函数负责为给定页面向我们的指定端点发起 GET 请求。

既然你已经重新熟悉了这个组件及其工作原理,让我们来测试它。

我们将首先创建我们的 MSW 请求处理器:

rest.get("*/react-query-paginated", (req, res, ctx) => {
    const page = req.url.searchParams.get("page");
    const pageOneData = {
      email: "email1",
      name: {
        first: "first1",
        last: "last1",
      },
    };
    const pageTwoData = {
      email: "email2",
      name: {
        first: "first2",
        last: "last2",
      },
    };
    const data = {
      results: [page > 0 ? pageTwoData : pageOneData],
    };
    return res(ctx.status(200), ctx.json(data));
  })

在前文片段中,我们创建了一个请求处理器并将其添加到我们的 handlers 数组中,它执行以下操作。

每当我们拦截到包含 /react-query-paginated 路径的端点的 GET 请求时,我们得到 page 查询参数以帮助我们定义我们将返回哪些数据。

我们返回一个包含第一页或第二页数据的 200 OK 响应,具体取决于接收到的页面查询参数。

这意味着对 danieljcafonso.builtwithdark.com/react-query-paginated?page=0&results=10 端点的 GET 请求将返回一个包含 pageOneData 对象的 200 OK 响应,而对 danieljcafonso.builtwithdark.com/react-query-paginated?page=1&results=10 端点的 GET 请求将返回一个包含 pageTwoData 对象的 200 OK 响应。

既然我们确信我们的组件在请求后总是会接收到相同的数据,我们可以编写我们的测试,并从以用户为中心的角度查看 PaginatedQuery 组件;以下是你可能识别出的测试场景:

  • 作为用户,我希望在打开页面后看到我的数据已加载:在这种情况下,我们希望我们的组件被渲染并检查是否显示了初始加载数据消息。

  • 作为用户,我希望在数据加载失败时看到错误消息:在这种情况下,我们希望我们的组件渲染并查看请求失败时是否显示了错误消息。

  • 作为用户,我希望看到最初获取的数据:在这种情况下,我们希望我们的组件渲染并等待第一页的数据被获取。

  • 作为用户,我希望点击 下一页 按钮并看到下一页的数据:在这种情况下,我们希望组件被渲染,确保我们有初始数据,并在点击 下一页 按钮后,等待直到获取第二页的数据。

  • 作为用户,我希望在获取新数据时看到获取指示器:在这种情况下,我们希望组件被渲染,确保我们有初始数据,并在点击 下一页 按钮后,确保获取指示器被渲染。

  • 作为用户,我希望在点击 下一页 上一页 时看到数据:在这种情况下,我们希望组件被渲染,确保我们有初始数据,并在点击 下一页 按钮后,确保第二页显示出来。然后我们点击 上一页 按钮并确保第一页的数据再次被渲染。

  • 作为用户,我希望在第一页时我的 上一页 按钮被禁用:在这种情况下,我们希望组件被渲染并确保我们有初始数据。由于我们处于第一页,我们希望 上一页 按钮被禁用。

  • 作为用户,我希望我的 下一页 按钮在等待新数据出现时被禁用:在这种情况下,我们希望组件渲染并确保我们有初始数据。在点击 下一页 按钮后,我们需要确保此按钮被禁用。

考虑到这些场景,这是我们将编写的测试 PaginatedQuery 组件的代码:

import PaginatedQuery from "../PaginatedQuery";
import { render, screen } from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { server } from "../mocks/server";
import { rest } from "msw";
describe("PaginatedQuery tests", () => {
  test("should render loading indicator on start", () => {
    render(<PaginatedQuery />);
    expect(screen.getByText("Loading initial data...")).
      toBeInTheDocument();
  });
  test("should render error on failed fetching", async () => {
    server.use(rest.get("*", (req, res, ctx) =>
      res(ctx.status(403))));
    render(<PaginatedQuery />);
    expect(
      await screen.findByText("Request failed with status
        code 403")
    ).toBeInTheDocument();
  });
  test("should render first page data", async () => {
    render(<PaginatedQuery />);
    const firstName = await screen.findByText(/first1/i);
    expect(firstName).toBeInTheDocument();
    expect(screen.getByText(/last1/i)).toBeInTheDocument();
  });
  test("should render second page data", async () => {
    render(<PaginatedQuery />);
    const firstName = await screen.findByText(/first1/i);
    expect(firstName).toBeInTheDocument();
    const nextPageButton = screen.getByRole("button", {
      name: "Next Page" });
    userEvent.click(nextPageButton);
    const secondPageFirstName = await screen.findByText
      (/first2/i);
    expect(secondPageFirstName).toBeInTheDocument();
    expect(screen.getByText(/last2/i)).toBeInTheDocument();
  });
  test("should show fetching indicator while fetching
    data", async () => {
    render(<PaginatedQuery />);
    const firstName = await screen.findByText(/first1/i);
    expect(firstName).toBeInTheDocument();
    const nextPageButton = screen.getByRole("button", {
      name: "Next Page" });
    userEvent.click(nextPageButton);
    expect(screen.getByText("Loading...")).
      toBeInTheDocument();
  });
  test("should change pages back and forth and render
    expected data", async () => {
    render(<PaginatedQuery />);
    expect(await screen.findByText(/first1/i)).
      toBeInTheDocument();
    expect(screen.getByText(/last1/i)).toBeInTheDocument();
    const nextPageButton = screen.getByRole("button", {
      name: "Next Page" });
    userEvent.click(nextPageButton);
    expect(await screen.findByText(/first2/i)).
      toBeInTheDocument();
    expect(screen.getByText(/last2/i)).toBeInTheDocument();
    const previousPageButton = screen.getByRole("button", {
      name: "Previous Page",
    });
    userEvent.click(previousPageButton);
    expect(await screen.findByText(/first1/i)).
      toBeInTheDocument();
    expect(screen.getByText(/last1/i)).toBeInTheDocument();
  });
  test("should have previous page button disabled on first
    page", async () => {
    render(<PaginatedQuery />);
    const previousPageButton = await screen.findByRole
      ("button", {
      name: "Previous Page",
    });
    expect(previousPageButton).toBeDisabled();
  });
  test("should have next page button disabled while
    changing pages", async () => {
    render(<PaginatedQuery />);
    const nextPageButton = await screen.findByRole
      ("button", {
      name: "Next Page",
    });
    userEvent.click(nextPageButton);
    expect(nextPageButton).toBeDisabled();
  });
});
  1. 我们首先进行必要的导入:

    1. 我们的 PaginatedQuery 组件。

    2. 我们的 renderscreen 工具来自 test-utils

    3. 来自测试库的 user-event 伴侣的 userEvent 工具。在这里需要注意的一点是我们使用的是 v14 之前的用户事件版本。

    4. 我们的 MSW server,以便我们可以为我们的测试场景之一创建自定义响应模拟。

    5. MSW rest 命名空间,为我们的测试场景之一创建相关的请求处理器。

  2. 我们创建我们的测试套件,并在其中创建我们的测试:

    1. 对于 "should render loading indicator on start" 测试,我们执行以下操作:

      1. 渲染我们的 PaginatedQuery 组件。

      2. 利用 getByText 查询断言 "Loading initial data…" 消息在 DOM 上。

    2. 对于 "should render error on failed fetching" 测试,我们执行以下操作:

      1. 利用我们的 server use 函数向当前的服务器实例添加一个请求处理器。在这种情况下,我们添加一个处理器来捕获每个 GET 请求("*" 表示此处理器将匹配每个路由)并返回 403 Forbidden,以便我们的请求失败。不用担心这会影响到其他测试,因为我们确保在 setupTests 文件中调用了 resetHandlers 函数。这将确保此自定义请求处理器只会用于此测试。

      2. 渲染我们的 PaginatedQuery 组件。

      3. 利用findByText查询await直到错误消息出现在 DOM 上。

    3. 对于“应渲染第一页数据”测试,我们执行以下操作:

      1. 渲染我们的PaginatedQuery组件。

      2. 等待直到第一页的姓名属性数据出现在 DOM 上。

      3. 断言姓氏属性也出现在 DOM 上。

    4. 对于“应渲染第二页数据”测试,我们执行以下操作:

      1. 渲染我们的PaginatedQuery组件。

      2. 等待直到第一页的数据出现在 DOM 上。

      3. 利用getByRole查询获取带有文本“应在加载数据时显示获取指示器”测试,我们执行以下操作:

        1. 渲染我们的PaginatedQuery组件。

        2. 等待直到第一页的数据出现在 DOM 上。

        3. 利用getByRole查询获取带有文本getByText的按钮,使用getByText查询来检查“加载中…”指示器是否出现在 DOM 上。

      4. 对于“应来回切换页面并渲染预期数据”测试,我们执行以下操作:

        1. 渲染我们的PaginatedQuery组件。

        2. 等待直到第一页的数据出现在 DOM 上,并断言它在那里。

        3. 利用getByRole查询获取带有文本getByRole的按钮,用于获取带有文本“在第一页应禁用上一页按钮”测试,我们执行以下操作:

          1. 渲染我们的PaginatedQuery组件。

          2. 利用findByRole查询等待直到“在切换页面时应禁用下一页按钮”测试,我们执行以下操作:

            1. 渲染我们的PaginatedQuery组件。

            2. 利用findByRole查询等待直到“下一页”按钮出现在 DOM 上,并点击它。

            3. 断言“下一页”按钮现在是禁用的。

        如您所见,我们可以以完全以用户为中心的方法测试我们的查询,并忘记实现细节。现在,让我们进入突变部分,看看它如何变得稍微难以采用以用户为中心的方法。

        测试突变

        你当然可以采用以用户为中心的方法进行突变,尽管在某些场景中这可能更困难。让我们回顾一下我们在第六章中编写的组件,看看它可能为什么以用户为中心的方法测试更困难:

        export const SimpleMutation = () => {
          const [name, setName] = useState("");
          const { mutate, isPaused } = useMutation({
            mutationFn: createUser,
          });
          const submitForm = (e) => {
            e.preventDefault();
            mutate({ name, age: 0 });
          };
          return (
            <div>
              {isPaused && <p> Waiting for network to come back </p>}
              <form>
                <input
                  name="name"
                  type={"text"}
                  onChange={(e) => setName(e.target.value)}
                  value={name}
                />
                <button disabled={isPaused} onClick={submitForm}>
                  Add
                </button>
              </form>
            </div>
          );
        };
        

        在前面的代码片段中,我们可以看到我们的SimpleMutation组件。现在,让我们尝试进行以用户为中心的方法练习,并了解我们可以编写哪些测试场景:

        • 作为用户,我想在突变进入暂停状态时看到暂停指示器:在这个场景中,我们想要渲染我们的组件,当我们尝试执行我们的突变时,暂停指示器消息应该出现。

        • 作为用户,我想在服务器上创建数据:在这个场景中,我们想要渲染我们的组件,填写表单,然后执行我们的突变。但是等等——我们的用户如何断言这一点?

        如您所见,最后一个场景有一个问题——UI 中缺乏我们的突变已成功执行的信息。

        通常,这类问题可以通过添加一个通知来解决,通知用户突变已成功执行。让用户知道突变成功始终是一个好的做法。按照这种方法,我们的测试将类似于以下内容:

        • 作为用户,我想在服务器上成功创建数据:在这个场景中,我们想要渲染我们的组件,填写表单,按下 添加 按钮,并等待成功消息出现

        正如你所见,我们现在有一种以用户为中心的方式来测试我们的突变。然而,出于某种原因,让我们假设我们无法对我们的 SimpleMutation 组件进行更改。我们如何确保我们的突变被执行?我们不得不求助于实现细节。我们的测试场景将类似于以下内容:

        • 作为用户,我想执行一个突变:在这个场景中,我们想要渲染我们的组件,填写表单,按下 添加 按钮,并断言我们的突变已被触发

        在本节中,我们将向您展示如何编写在理想(以用户为中心)的方法不可行时的测试。

        在我们编写测试之前,我们首先需要确保 MSW 请求被拦截并且成功:

        rest.post("*/name-api/*", (req, res, ctx) => {
            return res(
              ctx.status(201),
              ctx.json({
                hello: "user",
              })
            );
          })
        

        在前面的代码片段中,我们创建了一个请求处理器,并将其添加到我们的 handlers 数组中,该处理器执行以下操作。

        每当我们拦截到包含 /name-api/ 路径的端点的 POST 请求时,我们返回一个包含在主体中的 201 Created 响应,该响应包含一个具有 hello 属性的字符串对象。

        我们现在可以为我们 SimpleMutation 组件编写测试。为了回顾,以下是我们将要执行的测试:

        • 作为用户,我想在我突变进入暂停状态时看到暂停指示器

        • 作为用户,我想执行一个突变

        让我们看看我们创建的测试文件:

        import { axiosInstance } from "../api/userAPI";
        import { SimpleMutation } from "../Mutation";
        import { render, screen, waitFor } from
          "../utils/test-utils";
        import userEvent from "@testing-library/user-event";
        const postSpy = jest.spyOn(axiosInstance, "post");
        describe("SimpleMutation Tests", () => {
          test("data should be sent to the server", async () => {
            const name = "Daniel";
            render(<SimpleMutation />);
            const input = screen.getByRole("textbox");
            userEvent.type(input, name);
            userEvent.click(
              screen.getByRole("button", {
                name: /add/i,
              })
            );
            await waitFor(() =>
              expect(postSpy.mock.calls[0][1]).toEqual
                ({ name, age: 0 })
            );
          });
          test("on no network should display paused information", async () => {
            jest.spyOn(navigator, "onLine", "get").mockReturnValue
              (false);
            render(<SimpleMutation />);
            userEvent.click(
              screen.getByRole("button", {
                name: /add/i,
              })
            );
            const text = await screen.findByText("Waiting for
              network to come back");
            expect(text).toBeInTheDocument();
          });
        });
        

        让我们现在回顾一下前面代码片段中我们在做什么:

        1. 我们从我们的 API 文件中导入 axiosInstance,以及我们在 第六章 中看到的 SimpleMutation 组件,我们的自定义 render 函数,screen 对象,以及来自 test-utilswaitFor 函数。最后,我们从测试库的 user-event 伴侣中导入 userEvent 工具。

        这里需要注意的一点是我们使用的是 v14 之前的用户事件版本。

        1. 由于我们将其中一个测试与实现细节绑定,我们在 axiosInstancepost 函数上创建了一个 jest spy。这意味着我们可以检查 post 函数是否被调用,而不替换其实现。

        2. 我们创建我们的测试套件,并在其中创建我们的测试:

          1. 对于 "数据应该发送到服务器" 测试,我们执行以下操作:

            1. 创建一个变量来保存我们将要在突变中使用的名称。

            2. 渲染我们的 SimpleMutation 组件。

            3. 利用 getByRole 查询来获取我们的名称输入。

            4. 利用 userEventtype 事件并在我们的输入中键入我们的名称。

            5. 利用来自 userEventclick 事件并点击我们的 axiosInstancepost 函数,该函数使用我们的突变数据被调用。

          2. 对于 "on no network should display paused information" 测试,我们做以下操作:

            1. 由于我们想确保模拟离线状态,我们利用 spyOn 函数中的 mockReturnValue 函数来确保我们的 navigator onLine 属性返回 false。这将确保我们的代码知道它处于离线状态。

            2. 渲染我们的 SimpleMutation 组件。

            3. 利用来自 userEventclick 事件并点击 isPaused 属性为 true。因此,我们等待直到出现 "Waiting for network to come back" 消息。然后我们断言它已经在 DOM 上。

        从之前的测试中,我们了解到我们可以利用 Jest 间谍来检查我们的函数是否被调用,并确保我们的突变被执行。但这并不保证我们的组件在突变成功时的行为,因为我们没有渲染任何内容来让我们知道。在第一种情况下,始终确保你有用户所需的所有信息,这样他们就可以知道你的突变是成功的。如果你这样做,你可以从用户中心的角度进行测试,并避免实现细节。

        一个可能与测试相关的突变案例是在我们执行乐观更新时。然而,由于我们在本章中应用了上述模式之一,我们将在下一节中使用 React Hooks Testing Library 来测试它。

        测试使用 React Query 的自定义钩子

        在开发过程中,有时你的自定义钩子可能太复杂,无法与使用它们的组件一起测试。这可能是由于钩子的大小、复杂的逻辑,或者太多的场景,如果专注于用户中心的方法,会增加你的测试复杂性。为了解决这个问题,创建了 React Hooks Testing Library。

        现在,可能会非常诱人去到处使用这个,但别忘了,以用户为中心的方法最终会帮助你更快地找到问题并节省时间,如果你决定重构你的钩子工作方式。无论如何,如果你的钩子没有与组件一起使用或太复杂,React Hooks Testing Library 确实是值得考虑的。

        这是将 React Hooks Testing Library 添加到你的项目的步骤:

        • 如果你正在你的项目中运行 npm,请运行以下命令:

          npm install @testing-library/react-hooks react-test-renderer --save-dev
          
        • 如果你正在使用 Yarn,请运行以下命令:

          yarn add @testing-library/react-hooks react-test-renderer --dev
          
        • 如果你正在使用 pnpm,请运行以下命令:

          pnpm add @testing-library/react-hooks react-test-renderer --save-dev
          

        如果你正在使用 React 18 版本及以上,这里有一些需要注意的事情。你不需要安装 React Hooks Testing Library,因为从 13.1.0 版本开始,React Testing Library 包含 renderHook,它的工作方式与 React Hooks Testing Library 类似。

        如上一节末所述,我们将看到如何测试乐观更新。在我们编写测试之前,让我们看看应用本章中提到的模式后我们的代码看起来如何。

        要做到这一点,我们将利用之前显示的useOptimisticUpdateUserCreation钩子:

        import { useMutation, useQueryClient } from
          "@tanstack/react-query";
        import { userKeys } from "../../utils/queryKeyFactories";
        import { createUser } from "../../api/userAPI";
        const useOptimisticUpdateUserCreation = () => {
          const queryClient = useQueryClient();
          return useMutation({
            mutationFn: createUser,
            retry: 0,
            onSettled: () => queryClient.invalidateQueries
              (userKeys.all()),
            onMutate: async (user) => {
              await queryClient.cancelQueries(userKeys.all());
              const previousUsers = queryClient.getQueryData
                (userKeys.all());
              queryClient.setQueryData(userKeys.all(), (prevData) => [
                user,
                ...prevData,
              ]);
              return { previousUsers };
            },
            onError: (error, user, context) => {
              queryClient.setQueryData(userKeys.all(),
                context.previousUsers);
            },
          });
        };
        export default useOptimisticUpdateUserCreation;
        

        考虑到我们已经在 MSW 中处理了此钩子中的路由,我们可以开始考虑我们的测试。

        这些是我们将考虑的场景:

        • 我想在触发我的变更后立即执行乐观更新:在这种情况下,我们渲染我们的钩子,触发我们的变更,并等待直到受我们的变更影响的查询数据已更新。

        • 我想在我变更失败后撤销我的乐观更新数据:在这种情况下,我们渲染我们的钩子并触发我们的变更,当我们的变更失败时,我们的查询数据必须与触发变更之前保持相同。

        • 我想在我变更稳定后使我的查询失效:在这种情况下,我们渲染我们的钩子并触发我们的变更。一旦我们的变更稳定,我们检查查询是否已失效。

        考虑到这些场景,我们可以创建我们的测试。这是我们的测试文件可能的样子:

        import useOptimisticUpdateUserCreation from
          "../useOptimisticUpdateUserCreation";
        import { QueryClient, QueryClientProvider } from
          "@tanstack/react-query";
        import { renderHook } from "@testing-library/react-hooks";
        import { userKeys } from "../../../utils/
          queryKeyFactories";
        import { server } from "../../../mocks/server";
        import { rest } from "msw";
        const queryClient = new QueryClient({
          logger: {
            log: console.log,
            warn: console.warn,
            error: jest.fn(),
          },
        });
        const wrapper = ({ children }) => (
          <QueryClientProvider client={queryClient}>{children}
            </QueryClientProvider>
        );
        describe("useOptimisticUpdateUserCreation", () => {
          test("should perform optimistic update", async () => {
            queryClient.setQueryData(userKeys.all(), []);
            const name = "user";
            const age = 20;
            const { result, waitFor } = renderHook(
              () => useOptimisticUpdateUserCreation(),
              {
                wrapper,
              }
            );
            result.current.mutate({ name, age });
            await waitFor(() =>
              expect(queryClient.getQueryData(userKeys.all())).
                toEqual([{ name, age }])
            );
          });
          test("should revert optimistic update", async () => {
            queryClient.setQueryData(userKeys.all(), []);
            server.use(rest.post("*", (req, res, ctx) =>
              res(ctx.status(403))));
            const name = "user";
            const age = 20;
            const { result, waitFor } = renderHook(() =>
              useOptimisticUpdateUserCreation(), {
              wrapper,
            });
            result.current.mutate({ name, age });
            await waitFor(() => expect(result.current.isError).
              toBe(true));
            await waitFor(() =>
              expect(queryClient.getQueryData(userKeys.all())).
                toEqual([])
            );
          });
          test("should invalidate query on settled", async () => {
            queryClient.setQueryData(userKeys.all(), []);
            const invalidateQueriesSpy = jest.spyOn(queryClient,
              "invalidateQueries");
            const name = "user";
            const age = 20;
            const { result, waitFor } = renderHook(
              () => useOptimisticUpdateUserCreation(),
              {
                wrapper,
              }
            );
            result.current.mutate({ name, age });
            await waitFor(() => expect(result.current.isSuccess).
              toBe(true));
            expect(invalidateQueriesSpy).toHaveBeenCalledWith
              (userKeys.all());
          });
        });
        

        让我们现在回顾一下我们在前面的代码片段中做了什么:

        1. 我们首先进行必要的导入:

          1. 我们的useOptimisticUpdateUserCreation自定义钩子。

          2. 我们的QueryClientQueryClientProvider。记住,我们不会使用之前创建的customRender,所以我们必须在这里创建一个新的包装器。

          3. 从 React Hooks Testing Library 导入renderHook。如果您使用 React Testing Library 中的renderHook,请在那里导入它。

          4. 我们的userKeys工厂。

          5. 我们的 MSW server,这样我们就可以为我们的测试场景之一创建一个自定义响应模拟。

          6. MSW 的rest命名空间来为我们的测试场景之一创建相关的请求处理器。

        2. 我们创建我们的QueryClient实例并将其传递给我们的wrapper。这将用于包装我们的钩子以使用 React Query。

        3. 我们创建我们的测试套件,并在其中创建我们的测试:

          1. 对于"should perform optimistic update"测试,我们做以下操作:

            1. 确保在userKeys.all()键下的查询键的缓存查询数据是一个空数组。

            2. 创建nameage变量以避免在测试中使用魔法数字。

            3. 渲染我们的钩子,并从其中解构waitFor函数和result对象。

            4. 我们利用我们的result对象来访问我们的mutate函数并执行我们的变更操作。

            5. 我们使用waitFor函数循环我们的断言,直到它评估为true。在这种情况下,我们等待直到查询缓存已经根据userKeys.all()查询键缓存了乐观更新的数据。

          2. 对于"should revert optimistic update"测试,我们做以下操作:

            1. 确保在userKeys.all()键下的查询键的缓存查询数据是一个空数组。

            2. 利用我们的server use函数向我们的当前服务器实例添加一个请求处理器。在这种情况下,我们添加一个将捕获每个POST请求("*"表示此处理器将匹配每个路由)并返回403 Forbidden以使请求失败的处理程序。不用担心这会泄漏到其他测试中,因为我们确保在setupTests文件中调用了resetHandlers函数。这将确保此自定义请求处理器只会用于此测试。

            3. 再次创建nameage变量以避免测试中的魔法数字。

            4. 渲染我们的钩子并从其中解构waitFor函数和result对象。

            5. 利用我们的result对象来访问我们的mutate函数并执行我们的变异。

            6. 使用waitFor函数等待直到我们的钩子的isError属性为true

            7. 一旦我们确认我们的变异失败,我们再次利用waitFor函数等待,直到在userKeys.all()键下缓存的查询数据是我们变异之前的空数组。

          3. 对于"should invalidate query on settled"测试,我们做以下操作:

            1. 确保在userKeys.all()键下的查询键的缓存查询数据是一个空数组。

            2. 由于我们没有渲染查询以确保它在变异后更新,我们在queryClientinvalidateQueries方法上创建invalidateQueriesSpy

            3. 创建nameage变量以避免测试中的魔法数字。

            4. 渲染我们的钩子并从其中解构waitFor函数和result对象。

            5. 利用我们的result对象来访问我们的mutate函数并执行我们的变异。

            6. 等待直到我们的isSuccesstrue。这意味着我们的变异是成功的。

            7. 如果我们的变异成功,我们可以断言invalidateQueriesSpy被调用时带有userKeys.all()。这意味着我们的onSettled函数被调用,并且查询将在之后被无效化。

        现在我们已经处理了如何使用 React Hooks Testing Library 测试自定义钩子的方法。这全部关于渲染你的钩子并利用其结果来访问你的钩子返回的内容以执行你的操作和断言。

        只为了方便,并且让你看到我们测试查询的场景,让我们看看我们如何测试在检查数据是否 已获取部分中看到的useMultipleQueriesV2钩子。

        对于这个钩子,我们只需要一个测试场景:

        • 我希望我的并行查询能够获取数据:在这种情况下,我们渲染我们的钩子并等待它返回它获取的三个查询的数据。

        像之前的钩子一样,我们之前已经设置了我们的 MSW 请求处理器,所以我们不需要担心它们。

        让我们看看我们的useMultipleQueriesV2钩子的测试文件:

        import useMultipleQueriesV2 from "../useMultipleQueriesV2";
        import { QueryClient, QueryClientProvider } from
          "@tanstack/react-query";
        import { renderHook } from "@testing-library/react-hooks";
        const queryClient = new QueryClient();
        const wrapper = ({ children }) => (
          <QueryClientProvider client={queryClient}>{children}
            </QueryClientProvider>
        );
        describe("useMultipleQueriesV2", () => {
          test("should fetch all data", async () => {
            const { result, waitFor } = renderHook(() =>
              useMultipleQueriesV2(), {
              wrapper,
            });
            await waitFor(() =>
              expect(result.current.multipleQueries[0].data.hello).
                toBeDefined()
            );
            expect(result.current.multipleQueries[0].data.hello).
              toBe("userOne");
            expect(result.current.multipleQueries[1].data.hello).
              toBe("userTwo");
            expect(result.current.multipleQueries[2].data.hello).
              toBe("userThree");
          });
        });
        

        让我们现在回顾一下前面片段中我们在做什么:

        1. 我们首先进行必要的导入:

          1. 我们的useMultipleQueriesV2自定义钩子。

          2. 我们的QueryClientQueryClientProvider

          3. 来自 React Hooks Testing Library 的renderHook。如果您正在使用 React Testing Library 中的renderHook,请从那里导入。

        2. 我们创建我们的QueryClient实例,并将其传递给我们的wrapper。这将用于包装我们的钩子以使用 React Query。

        3. 我们创建我们的测试套件,并在其中创建我们的测试:

          • 对于"should fetch all data"测试,我们做以下操作:

            1. 使用renderHook函数渲染我们的钩子,并从其中解构result对象和waitFor函数。

            2. 等待第一个查询的数据定义。

            3. 由于数据现在已定义,我们断言第一个查询返回的对象上的hello属性具有userOne

            4. 我们还断言第二个查询返回的对象上的hello属性具有userTwo

            5. 我们还断言第三个查询返回的对象上的hello属性具有userThree

        如您所见,测试钩子和利用查询要简单得多,因为它主要只涉及渲染和断言。这是一个测试示例,我没有测试这个钩子,因为使用该组件进行测试要容易得多。只需检查我们在检查数据是否 已获取部分所做的测试即可。

        在考虑到所有这些知识后,您应该能够编写代码,然后在夜间睡得非常香,因为您还编写了有价值的测试,确保没有任何东西会出错。

        摘要

        在本章中,我们学习了如何测试利用 React Query 的组件和钩子。恭喜!感谢本章,您已经成为了一名全栈的 React Query 大师!

        您了解到 MSW 可以通过拥有几个请求处理程序来节省您在开发和测试 React Query 代码时的大量时间。

        您遇到了三种可以使您的代码更易于阅读和重用的模式(创建 API 文件、利用查询键工厂和创建钩子文件夹),并看到了它们在适应我们之前章节中看到的代码时的价值。

        最后,您学习了何时使用 React Testing Library 和 React Hooks Testing Library 来测试您的查询和突变,并且您将在编写测试时始终将以用户为中心的方法放在首位。

        再次恭喜!现在您应该能够在任何场景下利用 React Query,并在夜间睡得更好,因为您可以为它编写有价值的测试。现在,运用这些知识,去说服您的队友关于惊人的 TanStack Query 的价值,以及其 React 适配器,即 React Query,将使他们的服务器状态管理变得容易得多。

第九章:React Query v5 的变更有哪些?

在撰写本文时,@tanstack/react-query 的 5.0.0-alpha.1 版本刚刚发布。虽然稳定版本可能需要几周时间才能发布,但本书出版时,它可能已经成为了每次将 React Query 添加到您的项目时默认安装的版本。

为了确保您理解在 v5 发布后本书内容可能经历的变化,我们添加了这一章作为额外内容。

本章也可能作为从 v4 迁移到 v5 的辅助指南。

再次声明,*本章的代码片段是在 @tanstack/react-query 的 5.0.0-alpha.1 版本上测试的。其中一些内容可能仍然会发生变化,或者可能出现一些新的内容。无论如何,这些代码片段将在未来几个月内保持在线更新,直到稳定版本发布。您可以在 技术要求 部分提到的 GitHub 仓库中找到它们。

到本章结束时,您将了解 React Query v5 中所有对本书中某些内容有影响的变化。

在本章中,我们将涵盖以下主题:

  • 支持变更有哪些?

  • 仅使用对象格式

  • 移除记录器

  • loading 重命名为 pending

  • cacheTime 重命名为 gcTime

  • Hydrate 重命名为 HydrationBoundary

  • 移除 keepPreviousData 并使用 placeholderData

  • 引入了一种新的乐观更新方式

  • 向无限查询引入 maxPages

技术要求

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/State-management-with-React-Query/tree/feat/chapter_9

支持变更有哪些?

在这里需要注意的第一件事是浏览器支持已发生变化。从 v5 开始,您的浏览器需要与以下配置兼容:

  • Google Chrome 版本需要至少为版本 84

  • Mozilla Firefox 版本需要至少为版本 90

  • Microsoft Edge 版本需要至少为版本 84

  • Safari 版本需要至少为版本 15

  • Opera 版本需要至少为版本 70

现在我们已经了解了支持变更,让我们看看从 v4 到 v5 的哪些功能发生了变化,首先是自定义钩子和函数的对象格式。

仅使用对象格式

在 React Query 的 v4 版本中,大多数自定义钩子和函数都被重载以支持之前的模式。这意味着在您的代码中,以下代码片段中的两个 useQuery 钩子将是同一件事:

const { data } = useQuery({
    queryKey: ["api"]
    queryFn: fetchData,
});
const { data } = useQuery(["api"], fetchData);

如您从前面的代码片段中看到的,我们两次创建了一个带有 queryKey ["api"]queryFn fetchData 的查询。这是因为第二个和第一个示例只是同一个被重载的钩子的实例。

随着 v5 的引入,前面代码片段中显示的第二个示例不再受支持;因此,您只能通过传递一个包含所需选项的单个对象来使用您的钩子。以下是您现在需要遵循的语法:

useQuery({ queryKey, queryFn, ...options })
useMutation({ mutationFn, ...options })
useInfiniteQuery({ queryKey, queryFn, ...options })

如您从前面的代码片段中看到的,我们有三组 React Query 钩子,并且每个钩子都接收一些东西:

  • useQueryuseInfiniteQuery钩子需要接收queryKeyqueryFn作为必需参数。这些钩子允许您传递一些您应该已经从上一章中了解到的选项。

  • useMutation钩子需要接收mutationFn作为必需参数。它还允许您传递一些我们在第六章中了解到的选项,当时我们看到了useMutation钩子接收到的选项。

幸运的是,在整个书中,我们从一开始就遵循了对象方法,所以您应该从一开始就遵循正确的做法,不会因为变化而受到太大影响。

另一点需要注意是,这个更改适用于queryClient函数。例如invalidateQueriesrefetchQueriesprefetchQuery等函数也必须接收预期的对象。

现在您已经了解了单对象格式,我们可以看看 v5 中移除的一个东西——logger

移除日志记录器

之前,React Query 在生产环境中将失败的查询记录到控制台。这很快成为一个问题,因为我们的应用程序用户能看到他们不应该知道的实现细节错误。为了解决这个问题,添加了创建自定义日志记录器的功能,您可以通过它覆盖 React Query 用于日志记录的内容。

最近,React Query 在生产环境中移除了所有日志记录,并改进了开发日志。考虑到这种情况,在 v5 中,logger不再需要,已被移除。

从现在开始,console将用作默认日志记录器。

现在您知道了这个更改,让我们看看 v5 的第一个重命名——将loading重命名为pending

重命名 loading 为 pending

loading状态引起了一些混淆。这是因为大多数人将其与数据加载相关联;其次,如果您的查询由于enabled选项设置为false而被禁用,它将显示为loading。为了避免更多的混淆并有一个更清晰的名字,loading状态已被重命名。

这里是已应用的变化:

  • loading状态已被重命名为pending

  • 派生的isLoading状态已被重命名为isPending

  • 新增了一个派生的isLoading标志,它基本上等同于isPending && isFetching表达式

  • 考虑到已经有一个名为isInitialLoading的标志在做同样的事情,isInitialLoading标志已被弃用

让我们现在回顾一下在第四章中看到的ComponentA,并应用这些更改:

const ComponentA = () => {
  const { data, error, isPending, isError, isFetching } =
    useQuery({
    queryKey: [{ queryIdentifier: "api", apiName: apiA }],
    queryFn: fetchData,
  });
  if (isPending) return <div> Loading data... </div>;
  ...
};

如您从前面的代码片段中看到的,我们只需要将isLoading重命名为isPending

至于行为,也是一样的。我们需要注意的地方是,在第一次查询挂载后我们没有数据时,我们的status查询将是pending而不是loading,就像之前一样。

考虑到这一点,我们可以转向 v5 的下一个重命名——cacheTime现在变为gcTime

cacheTime重命名为gcTime

这是我个人最开心的变化之一,因为它可能是 React Query 中最被误解的选项。通常,人们认为cacheTime意味着数据将被缓存的时间长度,而不是它真正代表的含义,即缓存中不活跃数据在内存中保持的时间。

为了消除这种误解,cacheTime选项已被重命名为gcTime。这是因为gc通常是垃圾回收器的缩写。因此,从现在起,我们明确声明数据被垃圾回收的时间。

要使用它,你只需要将gcTime选项添加到你的useQuery/useMutation钩子中,如下所示:

useQuery({
    gcTime: 60000
});

在代码片段中,我们定义了在查询不活跃一分钟之后,数据将被垃圾回收。

为了总结重命名狂潮,让我们看看我们的Hydrate组件是如何变化的。

Hydrate重命名为HydrationBoundary

当在 SSR 中使用 hydrate 模式时,Hydrate组件并没有完全描述其含义。为了使其更加简洁并与其他在 TanStack Query 中定义的边界匹配,它被重命名为HydrationBoundary。因此,你现在需要在你的 Next.js 或 Remix 代码中将它重命名。

让我们现在看看代码片段是如何变化的。

Next.js hydrate 模式重命名

这就是我们的 Next.js _app组件现在的样子:

import { useState } from "react";
import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
export default function App({ Component, pageProps }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
  <QueryClientProvider client={queryClient}>
    <HydrationBoundary state={pageProps.
          dehydratedState}>
      <Component {...pageProps} />
    </HydrationBoundary>
  </QueryClientProvider>
  );
}

如您从前面的代码片段中看到的,我们只需要将Hydrate重命名为HydrationBoundary。其他一切保持不变。

Remix hydrate 模式更改

这就是我们的 Remix 根组件现在的样子:

import {
  ...
  Outlet,
} from "@remix-run/react";
import { useState } from "react";
import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import { useDehydratedState } from "use-dehydrated-state";
export default function App() {
  const [queryClient] = useState(() => new QueryClient());
  const dehydratedState = useDehydratedState();
  return (
  ...
     <QueryClientProvider client={queryClient}>
       <HydrationBoundary state={dehydratedState}>
         <Outlet />
       </HydrationBoundary>
     </QueryClientProvider>
   ...
  );
}

如您从前面的代码片段中看到的,就像我们在 Next.js 示例中看到的那样,我们只需要将Hydrate重命名为HydrationBoundary。其他一切保持不变。

现在你知道了这个变化,让我们看看影响我们进行分页查询的方式的一些变化。

移除keepPreviousData并使用placeholderData

keepPreviousData选项和isPreviousData标志已被移除。这是因为它们几乎与placeholderData选项和isPlaceholderData标志执行相同的任务。

为了使placeholderData能够完全执行与keepPreviousData完全相同的功能,之前的查询数据被添加为placeholderData函数的参数。这意味着通过利用 React Query 中的keepPreviousData自定义函数,useQuery将允许placeholderData以与之前keepPreviousData相同的方式工作。

让我们看看我们的PaginatedQuery代码在 v5 中的变化:

import { useQuery, keepPreviousData } from "@tanstack/react-query";
...
const PaginatedQuery = () => {
  ...
  const { isPending, isError, error, data, isFetching,
    isPlaceholderData } =
    useQuery({
      queryKey: userKeys.paginated(page),
      queryFn: fetchData,
      placeholderData: keepPreviousData,
    });
  if (isPending) {
    return <h2>Loading initial data...</h2>;
  }
  ...
  return (
    <>
     ...
        <button
          disabled={isPlaceholderData}
          onClick={() => setPage((old) => old + 1)}
        >
          Next Page
        </button>
...
    </>
  );
};
export default PaginatedQuery;

在前面的代码片段中,我们将我们的PaginatedQuery组件更改为适应由于删除keepPreviousData选项而必要的更改。这是我们所做的:

  1. 我们从 React Query 导入我们的keepPreviousData辅助函数。

  2. 由于我们需要重构组件,我们将isLoading重命名为isPending

  3. 我们将isPreviousData重命名为isPlaceholderData

  4. 我们将keepPreviousData选项重命名为placeholderData,并传递keepPreviousData辅助函数。

现在,v5 不仅删除和重命名了一些东西,还增加了一些新功能,包括一种新的执行乐观更新的方法。

介绍一种新的执行乐观更新的方法

在执行乐观更新时,你必须始终小心你对缓存所做的更改。一个打字错误或错误可能会意外影响你最初想要更改之外的其他查询。

幸运的是,随着 v5 的发布,TanStack Query 引入了一种执行乐观更新的方法,你可以完全依赖你的 UI 并停止更改你的缓存。

让我们看看如何:

export const NewOptimisticMutation = () => {
  const [name, setName] = useState("");
  const [age, setAge] = useState(0);
  const queryClient = useQueryClient();
  const { data } = useQuery({
    queryKey: userKeys.all(),
    queryFn: fetchAllData,
    retry: 0,
  });
  const mutation = useMutation({
    mutationFn: createUser,
    onSettled: () =>
      queryClient.invalidateQueries({ queryKey: userKeys.
        all() }),
  });
  return (
    <div>
      {data?.map((user, index) => (
        <div key={user.userID + index}>
          Name: {user.name} Age: {user.age}
        </div>
      ))}
      {mutation.isPending && (
        <div key={String(mutation.submittedAt)}>
          Name: {mutation.variables.name} Age:
            {mutation.variables.age}
        </div>
      )}
      <form>
        <input
          name="name"
          type={"text"}
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
        <input
          name="number"
          type={"number"}
          onChange={(e) => setAge(Number(e.target.value))}
          value={age}
        />
        <button
          disabled={mutation.isPaused ||
            mutation.isPending}
          type="button"
          onClick={(e) => {
            e.preventDefault();
            mutation.mutate({ name, age });
          }}
        >
          Add
        </button>
      </form>
    </div>
  );
};

在前面的代码片段中,我们可以看到 React Query 允许我们执行乐观更新的新方法。这是我们所做的:

  1. 创建姓名和年龄输入的状态变量及其相应的设置器。

  2. 获取我们的queryClient访问权限。

  3. 使用查询工厂all函数创建我们的查询,以提供查询键和fetchAllData作为query函数。

  4. 创建我们的突变,使用createUser作为突变函数。在这个突变内部,我们利用onSettled回调来使我们的查询无效。

  5. 在我们的组件返回中,我们创建以下div

    1. 我们使用查询的data来显示用户的资料。

    2. 我们使用我们的突变isPending标志来告诉我们是否当前有任何突变正在执行。如果这个标志是true,我们就可以访问和渲染我们的mutation变量在 DOM 上。

    3. 我们使用我们的姓名和年龄输入创建我们的受控表单。

    4. 我们还创建了一个按钮,当点击时,将触发我们的突变,并带上我们的姓名和年龄值。

如你现在所看到的,我们可以执行突变而不改变我们的查询缓存数据。这非常强大,可以节省你因搞乱缓存而造成的许多无意中的头疼。

通过检查前面的代码片段,你可能想知道突变是否与查询位于同一组件上。这意味着如果你有一个位于查询不同位置的突变,你将无法以这种方式执行乐观更新吗?不是的。

如果你想要在别处执行一个突变并执行乐观更新,你可以利用useMutationState自定义钩子。

这里是如何做的:

export const NewOptimisticMutationV2 = () => {
  const { data } = useQuery({
    queryKey: userKeys.all(),
    queryFn: fetchAllData,
    retry: 0,
  });

  const [mutation] = useMutationState({
    filters: { mutationKey: userKeys.userMutation(),
      status: "pending" },
    select: (mutation) => ({
      ...mutation.state.variables,
      submittedAt: mutation.state.submittedAt,
    }),
  });
  return (
    <div>
      {data?.map((user, index) => (
        <div key={user.userID + index}>
          Name: {user.name} Age: {user.age}
        </div>
      ))}
      {mutation && (
        <div key={String(mutation.submittedAt)}>
          Name: {mutation.name} Age: {mutation.age}
        </div>
      )}
      <MutationForm />
    </div>
  );
};

在前面的代码片段中,我们有NewOptimisticMutationV2组件。在这个组件中,我们在突变所在的组件外部执行乐观更新。在这个组件中,我们渲染我们的查询数据,并将我们的突变发生的组件MutationForm作为子组件渲染。

NewOptimisticMutationV2组件中,我们这样做:

  1. 使用我们的查询工厂all函数创建我们的查询,以提供查询键,并将fetchAllData作为query函数。

  2. 通过使用useMutationState钩子来获取我们的突变。

  3. 通过此钩子,我们访问当前具有挂起状态的突变,以及来自查询工厂的mutationKey userKeys.userMutation()

  4. 然后,利用useMutationState钩子的select选项来获取mutation变量和submittedAt属性。

  5. 在我们的组件返回中,我们创建了一个div,如下所示:

    1. 我们使用查询的data来显示我们的用户数据。

    2. 如果我们现在有任何正在执行的突变,我们可以访问和渲染 DOM 上的mutation变量。

在前面的描述中,我提到突变需要mutationKey才能被找到。这就是如何将其添加到您的突变中的方法:

const mutation = useMutation({
    mutationFn: createUser,
    mutationKey: userKeys.userMutation(),
  });

如您从前面的代码片段中可以看到,我们从查询工厂添加了userKeys.userMutation()键,并将其添加到useMutation钩子的mutationKey属性中。

现在您已经了解了执行乐观更新的新方法,让我们看看我们的无限查询发生了什么变化。

介绍无限查询的最大页面数

无限查询是一个帮助您构建无限列表的惊人模式。然而,在 v5 版本之前,它有一个问题——所有获取的页面都缓存在内存中;因此,您看到的页面越多,消耗的内存就越多。

为了防止这种情况发生并提高用户体验,maxPages选项被添加到useInfiniteQuery钩子中。此选项限制了将存储在查询缓存中的页面数。

这就是我们的无限查询示例,如第五章中所示,现在的样子:

const {
    isPending,
    isError,
    error,
    data,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage,
  } = useInfiniteQuery({
    queryKey: userKeys.api(),
    queryFn: getInfiniteData,
    defaultPageParam: 0,
    maxPages: 5,
    getNextPageParam: (lastPage, pages) => {
      return lastPage?.info?.nextPage;
    },
    getPreviousPageParam: (firstPage, pages) => {
return firstPage?.info?.prevPage
    }
  });
  if (isPending) {
    return <h2>Loading initial data...</h2>;
  }
  ...

在前一个代码片段中,我们可以看到 v5 之后的无限查询代码重构,并利用了maxPages选项。以下是变化的内容:

  1. 我们使用isPending而不是isLoading

  2. defaultPageParam选项指示 React Query 将使用哪个默认页面来获取第一页。此选项现在是必需的,因此已添加。

  3. 我们将5作为maxPages选项。这意味着只有五页将被存储在内存中。由于我们使用了此选项,因此现在需要getPreviousPageParam选项,以便 React Query 在需要时可以双向获取页面。

通过这种方式,我们现在已经封装了所有可能影响这本书的 React Query v5 的相关更改。

摘要

在本章中,我们了解了 v5 可能给 React Query 带来的所有变化。到现在为止,您应该知道您需要在浏览器中注意的支持更改,并理解为什么我们一直在整本书中遵循对象格式。

您已经看到了为什么移除logger,并理解为什么将loading重命名为pending更有意义。

说到重命名,您不会再感到困惑了,因为 gcTimecacheTime 更准确,而 HydrationBoundary 更好地代表了它的功能,比 Hydrate 更为恰当。

您已经了解到,对于分页查询,placeholderData 选项是最佳选择,而 keepPreviousData 已被移除。

最后,您了解了一种新的执行乐观更新而不更新缓存的方法,通过利用 maxPages 选项,您还找到了在无限查询中节省内存的方法。

如您可能从之前我说的话中回忆起来,这是在 React Query 的 alpha 版本中进行的测试,所以其中一些事情可能仍然会改变。

看到这些变化真是令人兴奋,因为它们逐渐改进了这个库。

个人来说,我迫不及待地想看到 TanStack Query 接下来的发展。随着每个新版本的推出,它总是找到一种新的方法让我的服务器状态处理变得更简单。希望从现在开始,它也能为您做到同样的事情。

posted @ 2025-09-08 13:02  绝不原创的飞龙  阅读(26)  评论(0)    收藏  举报