继续学习React Query。
添加数据
我们已学习了查询数据,现在来学习更改数据,我们要在前面的数据列表前加上一个表单,用于提交数据。
打开我们的项目,能看到 src/react-query/TodoForm.tsx组件。这个组件非常简单,只有一个Input,并用Ref引用它,用于获取输入。
// TodoForm.tsx
import { useRef } from 'react';
const TodoForm = () => {
const ref = useRef<HTMLInputElement>(null);
return (
<form className="row mb-3">
<div className="col">
<input ref={ref} type="text" className="form-control" />
</div>
<div className="col">
<button className="btn btn-primary">Add</button>
</div>
</form>
);
};
export default TodoForm;
再到App中使用它。
// App.tsx
import "./App.css";
import TodoForm from "./react-query/TodoForm";
import TodoList from "./react-query/TodoList";
function App() {
return (
<>
<TodoForm />
<TodoList />
</>
);
}
export default App;

现在来处理提交事件。
定义一个 useMutation的Hook名为addTodo,用于添加数据。需要提供更新函数的属性 mutationFn,我们定义该函数需提供参数 todo对象,然后调用axios.post方法将todo对象添加到todos列表。
再在Form的onSubmit中使用,首先调用event.preventDefault禁用默认行为,再判断Input是否为空,不为空的话,就调用addTodo.mutate方法,也就是前面的mutationFn属性定义的函数,为了不转移注意力,我们在提供的todo对象用了硬编码,比如id为0,userId为1等。
接着需要更新列表项。
useMutation有一些回调函数的属性,比如onSuccess表示成功的处理函数,onError表示失败的处理,onSettled表示不管成功或失败的处理函数等等,这里就用onSuccess。
onSuccess需要提供一些参数,第1个参数表示成功写入后端的数据,我们命名为savedTodo,第2个参数表示我们传递过去的数据,这里命名为newTodo。
具体如何更新列表项呢?有两种技术。
第1种是指示缓存无效,这样ReactQuery就会自动从后端获取数据刷新,但这里不适用,因为我们用的是伪API,不会实际添加,重新从后端获取不会有新的条目,但实际场景中是可以这么做的,具体是:为使用ReactQuery,前面我们在main.tsx中,定义了queryClient对象,在TodoForm中,我们调用用useQueryClient()获取。然后通过queryClient.invalidateQueries方法同时提供queryKey属性使该键值的缓存失效。
第2种方法是更新缓存,通过queryClient.setQueryData方法实现,该方法需要提供键值(这里是[“todos”]),以及更新函数来实现。具体代码如下。
// TodoForm.tsx
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { Todo } from "./hooks/useTodos";
import axios from "axios";
const TodoForm = () => {
const queryClient = useQueryClient();
const ref = useRef<HTMLInputElement>(null);
const addTodo = useMutation({
mutationFn: (todo: Todo) =>
axios
.post<Todo>("https://jsonplaceholder.typicode.com/todos", todo)
.then((res) => res.data),
onSuccess: (savedTodo, newTodo) => {
// 有两种技术,第一种是使缓存无效,这样ReactQuery就会从服务后端获取最新的数据
// 但这种方法对我们的伪API是无效的,伪API只是模拟不会实际在后端添加条目
// queryClient.invalidateQueries({
// queryKey: ["todos"],
// });
// 第二种方法是更新缓存
queryClient.setQueryData<Todo[]>(["todos"], (todos) => [
savedTodo,
...(todos || []),
]);
},
});
return (
<form
className="row mb-3"
onSubmit={(event) => {
event.preventDefault();
if (ref.current && ref.current.value)
addTodo.mutate({
id: 0,
title: ref.current?.value,
completed: false,
userId: 1,
});
}}
>
<div className="col">
<input ref={ref} type="text" className="form-control" />
</div>
<div className="col">
<button className="btn btn-primary">Add</button>
</div>
</form>
);
};
export default TodoForm;
回到页面,在输入框输入条目,并点”Add”,检查列表会增加该条目。
处理数据更改的错误
我们在组件里添加记录时,如果有错误,就展示出来。为了不使TypeScript报错,useMutation添加类型:useMutation<Todo, Error, Todo>.
// TodoForm.tsx
......
const addTodo = useMutation<Todo, Error, Todo>(
......
);
return (
<>
{addTodo.error && (
<div className="alert alert-danger">{addTodo.error.message}</div>
)}
<form
......
</form>
</>
);
};
export default TodoForm;
我们可以故意修改成错误的API端点,会看到有错误提示。

显示数据更改过程
数据更改是需要花时间的,我们来显示提示。与查询一样,也有一个isLoading属性。
另外在添加条目时,我们希望清空输入框,调用ref.current.value=‘’即可。
// TodoForm.tsx
......
queryClient.setQueryData<Todo[]>(["todos"], (todos) => [
savedTodo,
...(todos || []),
]);
if (ref.current) ref.current.value = "";
......
<div className="col">
<button disabled={addTodo.isLoading} className="btn btn-primary">
{addTodo.isLoading ? "Adding..." : "Add"}
</button>
......
乐观更新
目前是更新后端后再作UI的展示,响应方面稍有些慢,可以改成乐观更新。稍有些步骤。
首先将之前在onSuccess的代码移到 onMutate里,这样就先更新UI。考虑到后端调用失败需要还原,将更新前的Todo列表保存到previousTodos里,该变量也有可能是空列表,所以后面加上”||[]”,否则TypeScript可能报错,在onMutate最后返回 {previousTodos}。在useMutation里,各回调函数之间可以传递上下文,onMutate返回的数据在其他回调函数如 onError中使用。
我们定义了一个interface AddTodoContext,表示各回调函数中的上下文的数据格式,并在调用useMutation时,提供这个类型参数。
onSuccess中,我们需要更新,因为传递的id我们随便设了0,实际保存的ID由后端生成,通过 map方法更新。
onError中,检查上下文是否为空,非空的话将上下文的内容还原并更新UI。
// TodoForm.tsx
......
interface AddTodoContext {
previousTodos: Todo[];
}
......
const addTodo = useMutation<Todo, Error, Todo, AddTodoContext>({
......
onMutate(newTodo) {
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]) || [];
queryClient.setQueryData<Todo[]>(["todos"], (todos) => [
newTodo,
...(todos || []),
]);
if (ref.current) ref.current.value = "";
return { previousTodos };
},
onSuccess: (savedTodo, newTodo) => {
queryClient.setQueryData<Todo[]>(["todos"], (todos) =>
todos?.map((todo) => (todo === newTodo ? savedTodo : todo))
);
},
onError(error, newTodo, context) {
if (!context) return;
queryClient.setQueriesData<Todo[]>(["todos"], context.previousTodos);
},
});
......
创建自定义更改Hook
目前的实现明显包含太多了,既知道后端细节,也知道如何实现乐观更新,不符合单一职责原则,需要进行分离。
新建文件 src/react-query/hooks/useAddTodo.ts。
将有关后端调用的操作及缓存的操作全部移到这个文件里。直接返回useMutation对象。之前在onMutate里有清空 input的操作,这属于UI层面的操作,在useAddTodo.ts里实现就不对了,于是提取成一个 onAdd回调函数,由消费者即TodoForm.tsx实现。
useAddTodo.ts里修改了以下代码片段,参数todos给出默认值[],这样就不用在后面 用 …(todos||[]),个人感觉更清晰一点。
…… (todos=[]) => [newTodo, …todos,])
另外,多次用到的键值 [“todos”]提取成CACHE_KEY_TODOS,并放在一个单独的文件constants.ts里。
接着修改TodoForm.tsx,调用useAddTodo,并提供onAdd回调函数的实现。
这样之后,就非常清晰,可重用性也增强了。
// useAddTodo.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Todo } from "./useTodos";
import axios from "axios";
import { CACHE_KEY_TODOS } from "../constants";
interface AddTodoContext {
previousTodos: Todo[];
}
const useAddTodo=(onAdd:()=>void)=>{
const queryClient = useQueryClient();
return useMutation<Todo, Error, Todo, AddTodoContext>({
mutationFn: (todo: Todo) =>
axios
.post<Todo>("https://jsonplaceholder.typicode.com/todos", todo)
.then((res) => res.data),
onMutate(newTodo) {
const previousTodos = queryClient.getQueryData<Todo[]>(CACHE_KEY_TODOS) || [];
queryClient.setQueryData<Todo[]>(CACHE_KEY_TODOS, (todos=[]) => [
newTodo,
...todos,
]);
onAdd();
return { previousTodos };
},
onSuccess: (savedTodo, newTodo) => {
queryClient.setQueryData<Todo[]>(CACHE_KEY_TODOS, (todos) =>
todos?.map((todo) => (todo === newTodo ? savedTodo : todo))
);
},
onError(error, newTodo, context) {
if (!context) return;
queryClient.setQueriesData<Todo[]>(CACHE_KEY_TODOS, context.previousTodos);
},
});
}
export default useAddTodo;
constants.ts:
// constants.ts export const CACHE_KEY_TODOS = ['todos']
TodoForm.tsx:
// TodoForm.tsx
......
const TodoForm = () => {
const ref = useRef<HTMLInputElement>(null);
const addTodo = useAddTodo(() => {
if (ref.current) ref.current.value = "";
});
return (
......
);
};
export default TodoForm;
一个小技巧,当我们将相关代码从TodoForm移到useAddTodo后,TodoForm的一些import引用就用不到了,可以按ctrl+shift+p,输入Organize Imports指令就可以快捷清除不用的引用。
创建可重用的API客户端
目前有两个自定义Hook,useTodos和useAddTodo都用到了API同样的API端点,有点重复,我们来提取可重用的API。
新建文件 src/react-query/services/apiClient.ts,将API端点封装起来。
使用了APIClient类,在构造函数里获取具体的端点。该类封装了getAll和post方法,注意我们使箭头函数的方式,避免调用时javascript中的this指向的问题。
APIClient类使用了一个通用的类型<T>,这样调用它时可以提供具体的类型。
这种封装方法,我们在React入门系列里已有过提及。
//apiClient.ts
import axios from "axios";
const axiosInstance = axios.create({
baseURL:"https://jsonplaceholder.typicode.com",
})
class APIClient<T>{
endpoint:string;
constructor(endpoinrt:string){
this.endpoint = endpoinrt;
}
getAll = () =>{
return axiosInstance.get<T[]>(this.endpoint).then(res=>res.data);
}
post = (data:T) =>{
axiosInstance.post<T>(this.endpoint,data).then(res=>res.data)
}
}
export default APIClient;
useTodos中调用ApiClient。
// useTodos.ts
import APIClient from "../services/apiClient";
......
const apiClient = new APIClient<Todo>('/todos');
......
const useTodos = () =>{
return useQuery<Todo[], Error>({
queryFn: apiClient.getAll,
......
});
}
export default useTodos;
useAddTodo同样的调用。
// useAddTodo.ts
......
import APIClient from "../services/apiClient";
const apiClient = new APIClient<Todo>('/todos');
......
return useMutation<Todo, Error, Todo, AddTodoContext>({
mutationFn:apiClient.post,
......
创建可重用的HTTP 服务
目前我们的程序还不错,但还有重复,在useTodos和useAddTodo里重复使用了“/todos”端点,可以再提取成HTTP服务。
新建文件 src/react-query/services/todoService.ts。将 interface Todo 和 创建 APIClient的实例移到里面,并export。
// todoService.ts
import APIClient from "./apiClient";
export interface Todo {
id: number;
title: string;
userId: number;
completed: boolean;
}
export default new APIClient<Todo>('/todos');
接着修改 useTodos.ts 和 useAddTodo.ts调用 todoService即可。
// useTodos.ts
......
import todoServicrs, { Todo } from "../services/todoServicr";
const useTodos = () =>{
return useQuery<Todo[], Error>({
......
queryFn: todoServicrs.getAll,
});
}
export default useTodos;
// useAddTodo.ts
import todoServicrs, {Todo} from "../services/todoServicr";
......
return useMutation<Todo, Error, Todo, AddTodoContext>({
mutationFn:todoServicrs.post,
......
应用程序层次
看一下目前我们的应用程序层次,非常清晰,可维护性、可读性及伸缩性也很不错。
API Client 用于与后端交互。
HTTP Services 用于封装HTTP的方法。
Custom Hooks用于处理获取的数据及缓存。
Components 用于前端的展示。

小结
本篇继续学习React Query,介绍使用React Query添加数据及重构应用程序的相关技巧。
下一篇利用React Query 修改完善我们在前一系列中创建的game-hub应用。
