UPDATE: This post was easily misunderstood. Let me offer a few clarifications.
更新:这篇文章容易被误解。让我提供一些澄清。
- I am not presenting a universal solution for managing state in this post. I am only describing how my understanding of state management has changed.
我在这篇文章中并没有提供一个通用的 state 管理解决方案。我只是描述了我对 state 管理的理解是如何变化的。 - Initially, several statements in this post accidentally implied that certain libraries were incapable of certain use cases. I have since removed the offending statements since they are not vital to this post.
最初,这篇文章中有一些陈述错误地暗示某些库在某些用例中是无法使用的。我已经删除了这些有误的陈述,因为它们对本文并不重要。 - What I realized was that moving state operations (
setState
) and side effects (useEffect
) outside of render functions and custom hooks led to much nicer code. That said, I am not saying that you should avoiduseState
,useEffect
or that you must use a state management library. I only ask that you give this point some thought and apply it where you see fit.
我意识到将状态操作(setState
)和副作用(useEffect
)移出渲染函数和自定义挂钩可以使得代码更加整洁。不过,我并不是说你应该完全避免使用useState
,也不是说你必须使用状态管理库。我只是希望你对此进行一些思考,并在合适的地方应用这一点。
Over the last 7 months, I tried 5 state management libraries (Zustand, Jotai, Valtio, MobX, XState) and 1 data fetching library (Tanstack query) in monthly posts like this one. During that time, I also used Redux toolkit and React Router (it's a data-fetching library now) on professional projects.
在过去 7 个月里,我尝试了 5 个状态管理库(Zustand、Jotai、Valtio、MobX、XState)和 1 个数据获取库(Tanstack query),像这篇帖子一样,每个月都会写一篇相关的文章。在这段时间里,我还曾在专业项目中使用了 Redux toolkit 和 React Router(现在它是一个数据获取库)。
That's a lot of libraries and in this post, I will describe my realizations about managing state after working with all of them.
那有很多库,在这篇文章中,我会描述在使用了所有这些库之后我对管理状态的一些认识。
Prologue 序言
If you are wondering how I ended up trying 6 different libraries over the last few months, you can check out this post. TLDR; I kept hearing jargon that I didn't understand and I wanted to see if any of these new libraries were any good. Here are my takeaways.
如果你想知道我为什么在过去的几个月里尝试了 6 个不同的库,你可以看看这篇文章。长话短说;我听到很多我不懂的专业术语,我想看看这些新库有没有用。这是我的一些收获。
Takeaways 收获
State management is not a big deal anymore
状态管理不再是大事了
For most of my career, working with React meant working with class components and vanilla Redux. During that era of React, we built everything on top of Redux. Async actions? Write a "thunk" which will expose it as several different Redux actions. Form state? Put it in Redux. Data from the API? Put it in Redux. So state management libraries ruled that era of React apps. They were the core of the app and they affected its entire structure.
在我的职业生涯中,使用 React 大多意味着使用类组件和原生的 Redux。在那个 React 的时代,我们基本上都是在 Redux 上构建一切。异步操作?写一个“thunk”,将其暴露为几个不同的 Redux 操作。表单状态?放在 Redux 中。API 数据?也放在 Redux 中。因此,在那个 React 应用的时代,状态管理库是核心,并且影响了整个应用的结构。
But things have changed. Now we have hook libraries that implement huge chunks of functionality. Data fetching libraries such as Tanstack query (previously React query) have become particularly popular. You could implement a big part of a React app's functionality using only these hooks. You could even skip state management libraries entirely since only a few pieces of state will be left to be managed.
但情况已经改变。现在我们有了实现大量功能的钩子库。像 Tanstack query(之前称为 React query)这样的数据获取库特别流行。你只需要使用这些钩子就可以实现 React 应用的大部分功能。甚至你可以完全跳过状态管理库,因为只需要管理少量的状态。
The new norm for building React apps is to use several hook libraries, where each one of them handles a specific piece of functionality (e.g. date fetching, forms, global state). Using these hooks, we can build faster while taking advantage of the tested patterns and code that those hooks provide. But there is a catch.
使用多个钩子库来构建 React 应用现在是新的常态,每个钩子库处理特定的功能(例如,日期获取、表单、全局状态)。使用这些钩子,我们可以更快地构建应用并利用这些钩子提供的测试模式和代码。但有一个问题。
Hooks push state operations into the render function
Hooks 将状态操作推入渲染函数
I starting seeing glimpses of this after refactoring Timo to use Tanstack query but I didn't realize it. It was only after I refactored a real app to use RTK query that I started to understand. Let's walk through this problem with Tanstack query's hooks but I think it applies to any hook library.
我是在重构 Timo 使用 Tanstack query 后开始看到这一点的,但当时并没有意识到。直到我重构了一个实际应用使用 RTK query 后,我才开始理解。让我们通过 Tanstack query 的 Hooks 来探讨这个问题,但我认为这适用于任何 Hook 库。
In a nutshell, Tanstack query has query hooks for reading data from an API and mutation hooks for writing data to it.
简而言之,Tanstack 查询有查询钩子用于从 API 读取数据,以及突变钩子用于向 API 写入数据。
// Original: https://github.com/bashlk/timo/blob/main/packages/tanstack-query/src/routes/Entries/Entries.jsx
import { useMutation, useQuery } from '@tanstack/react-query';
import { listEntries, updateEntry, deleteEntry } from '@timo/common/api';
const Entries = () => {
// Hook for getting data from the API
const { data: entries, isFetching, isError, error, refetch } = useQuery({
queryKey: ['entries', params],
queryFn: () => listEntries(params)
});
// Hook for submitting data to the API
const { mutate: update, error: updateError, variables: updateVariables, isPending: isUpdating } = useMutation({
mutationFn: updateEntry,
onSuccess: () => {
refetch();
}
});
}
Using these hooks to display the result of a single query or run a single mutation is very straightforward. But often you need more custom behavior such as
使用这些钩子来显示单个查询的结果或运行单个突变是非常直接的。但通常你需要更多的自定义行为 such as
- Making multiple queries to different endpoints
对不同端点进行多次查询 - Combining multiple query responses
组合多个查询响应 - Using the response of one query as parameters to another query
使用一个查询的响应作为另一个查询的参数 - Making different queries under different conditions
在不同条件下进行不同的查询 - Sending edited query responses back to the server
将编辑后的查询响应发送回服务器 - Triggering mutations when a specific state is entered
触发特定状态进入时的突变
Some of this behavior can be implemented by customizing the Tanstack query hooks. But for the rest you are out of luck and you will be forced to use extra useState
or useEffect
hooks alongside these hooks to implement more custom behavior. In the past, this kind of custom logic was in a state management library construct (e.g. reducer). But now it is within the render function and that is a terrible place for them. Speaking of which,
The render function is a terrible place for working with state
render 函数是处理状态的糟糕地方
I am now convinced that trying to work with state within the render function is the cause of a lot of ugly React code. If you are changing state values within a useEffect
hook, you will end up with code that is extremely brittle and unpredictable.
我现在确信,在 render 函数中尝试处理 state 是导致很多丑陋的 React 代码的原因。如果你在 useEffect
钩子中改变 state 值,最终会得到非常脆弱和不可预测的代码。
As more useEffect
hooks are added to a component, it becomes harder to understand and more prone to errors. I think this is due to several reasons.
随着组件中添加的 useEffect
挂钩增多,它变得越来越难以理解,并且更容易出错。我认为这有几个原因。
- It is difficult to control when a
useEffect
hook runs. It is triggered when any item in the dependency array changes and that is often not a reliable signal. The problem only gets worse when there are multiple dependencies.
控制钩子运行的时间很难。它会在依赖数组中的任何项改变时触发,但这往往不是一个可靠的信号。当有多个依赖时,问题会变得更糟。 useEffect
relies on shallow equality to check if an item in the dependency array has changed. This is hard to ensure within the render function since the reference to objects and functions declared within it change every time it re-renders. So accidentally introducing an unstable dependency into auseEffect
can break it.
useEffect
依赖浅等价来检查依赖数组中的项是否发生变化。由于在渲染函数中声明的对象和函数每次重新渲染时都会改变引用,因此在渲染函数内部很难确保这一点。因此,不小心引入不稳定的依赖项可能会破坏useEffect
。- When a state update is triggered in a
useEffect
, that component re-renders and theuseEffect
hook runs again. This is an endless loop and the only way to prevent it is to carefully maintain the dependency array and/or add extra checks within the hook. However, changing the behavior of the component can easily break these checks.
当一个状态更新在useEffect
中触发时,该组件会重新渲染并且useEffect
钩子会再次运行。这是一个无限循环,唯一的方法是仔细维护依赖数组并在钩子中添加额外的检查。然而,改变组件的行为可能会轻易地破坏这些检查。
// A timer or anything around timed events is a great breeding ground for useEffect hell
// Original: https://github.com/bashlk/timo/blob/main/packages/common/components/Timer/Timer.jsx
import { useEffect, useState } from 'react';
const Timer = ({ value, active, onPaused }) => {
// The actual ticking of the timer is done here so that only this component
// re-renders as the timer value changes
const [currentValue, setCurrentValue] = useState(value);
// The state-ops are split into several useEffect hooks for clarity
// but it is still not clear or nice to work with
useEffect(() => {
let interval;
if (active) {
interval = setInterval(() => {
setCurrentValue((val) => val + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [active, value]);
useEffect(() => {
if (!active) {
onPaused(currentValue);
}
}, [active, currentValue, onPaused]);
useEffect(() => {
setCurrentValue(value);
}, [value]);
// Format duration to HH:MM:SS
const formattedValue = new Date(currentValue * 1000).toISOString().slice(11, 19);
return (
<div>{formattedValue}</div>
);
};
useEffect
hell often happens gradually. It starts with a single useEffect
hook that can feel manageable. But, as a component evolves and more functionality is added, it can become a monster. The useReducer
hook can help a bit in these cases. But there is a better way.
地狱往往是在不知不觉中降临的。它始于一个看似可管理的 useEffect
钩子。但随着组件的演变和更多功能的添加,它可能会变成一个怪物。 useReducer
钩子在这些情况下能起到一些作用。但有更好的方法。
The bliss of not working with state in the render function
不处理状态的 render 函数带来的愉悦
My first impression of Jotai was not very good. I still think it is tricky to use.
我对 Jotai 的第一印象不是很好,我觉得它用起来还是挺复杂的。
But I kept thinking about it in the months that followed. Moving state operations out of the render function led to components that were much nicer to work with. So when I got around to trying XState, I set myself the goal of using zero useState
and useEffect
hooks. To my surprise, I succeeded and it was the best I felt about working with React in a really long time. The components felt lean and clear compared to their hook-laden counterparts.
但我在随后的几个月里一直都在思考这个问题。将状态操作移出渲染函数后,组件变得更容易处理。所以当我有机会尝试 XState 时,我给自己设定了一个目标,即完全不使用 useState
和 useEffect
钩子。令我惊讶的是,我成功了,这是我感觉使用 React 最舒服的一次。这些组件感觉更加精简和清晰,与那些充斥着钩子的组件相比更是如此。
// The Entries components after refactoring to Tanstack query
// Original: https://github.com/bashlk/timo/blob/main/packages/tanstack-query/src/routes/Entries/Entries.jsx
const Entries = ({ history }) => {
const [params, setParams] = useState({
from: getDateString(firstDateOfMonth),
to: getDateString(lastDateOfMonth)
});
const { data: entries, isFetching, isError, error, refetch } = useQuery({
queryKey: ['entries', params],
queryFn: () => listEntries(params)
});
const statusMessage =
isFetching ? 'Loading...' :
isError ? error.message :
entries?.length === 0 ? 'No entries found' : null;
const { mutate: update, error: updateError, variables: updateVariables, isPending: isUpdating } = useMutation({
mutationFn: updateEntry,
onSuccess: () => {
refetch();
}
});
const updateStatus = updateError ? updateError.message : isUpdating ? 'Saving...' : null;
const { mutate: deleteEntryM, error: deleteError, variables: deleteVariables, isPending: isDeleting } = useMutation({
mutationFn: deleteEntry,
onSuccess: () => {
refetch();
}
});
const deleteStatus = deleteError ? deleteError.message : isDeleting ? 'Deleting...' : null;
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
setParams({
from: formData.get('from'),
to: formData.get('to')
});
};
const handleNewClick = () => {
history.push('./new');
};
return (
...
)
}
// The Entries components after refactoring to Jotai
// Original: https://github.com/bashlk/timo/blob/main/packages/jotai/src/routes/Entries/Entries.jsx
const Entries = () => {
const groupedEntries = useAtomValue(entriesGroupedByDateAtom);
const entriesDuration = useAtomValue(entriesDurationAtom);
const entriesCount = useAtomValue(entriesCountAtom);
const entriesDurationsGroupedByDate = useAtomValue(entriesDurationsGroupedByDateAtom);
const { isLoading, isError, error } = useAtomValue(entriesStatusAtom);
const statusMessage =
isLoading === 'loading' ? 'Loading...' :
isError ? error.message :
entriesCount === 0 ? 'No entries found' : null;
const { mutate: updateEntry, error: updateError, isPending: isUpdating, variables: updateVariables } = useAtomValue(updateEntryAtom);
const updateStatus = updateError ? updateError.message : isUpdating ? 'Saving...' : null;
const { mutate: deleteEntry, error: deleteError, isPending: isDeleting, variables: deleteVariables } = useAtomValue(deleteEntryAtom);
const deleteStatus = deleteError ? deleteError.message : isDeleting ? 'Deleting...' : null;
const [startDate, setStartDate] = useAtom(entriesStartDateAtom);
const [endDate, setEndDate] = useAtom(entriesEndDateAtom);
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
setStartDate(formData.get('from'));
setEndDate(formData.get('to'));
};
const setLocation = useSetAtom(baseAwareLocationAtom);
const handleNewClick = () => {
setLocation({ pathname: '/new' });
};
return (
...
)
}
// Entries component after refactoring to XState
// Original: https://github.com/bashlk/timo/blob/main/packages/xstate/src/routes/Entries/Entries.jsx
const Entries = () => {
const {
groupedEntries,
totalDuration,
statusMessage,
filter,
itemStatusMessage
} = useChildMachineState('entries', state => state.context);
const entriesMachine = useChildMachine('entries');
const rootMachine = useRootMachine();
const handleEdit = (updatedEntry) => {
entriesMachine.send({
type: 'updateEntry',
updatedEntry
});
};
const handleDelete = (entryId) => {
entriesMachine.send({
type: 'deleteEntry',
entryId
});
};
const handleFilter = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
entriesMachine.send({
type: 'filter',
startDate: formData.get('from'),
endDate: formData.get('to')
});
};
const handleNewClick = () => {
rootMachine.send({
type: 'pushRoute',
route: 'newEntry'
});
};
return (
...
)
}
Writing "state op-free" components that only render state values and emit actions is not a new idea. Most state management libraries encourage us to do this. But as React hooks arrived, I think we forgot all about it. We were tired of writing boilerplate Redux code so we were happy to write less code for a change. But that code can easily turn into a mess.
编写只渲染状态值并发出动作而不使用状态管理库的“state op-free”组件并不是一个新的想法。大多数状态管理库都鼓励我们这样做。但随着 React Hooks 的到来,我认为我们忘记了这一点。我们厌倦了编写冗长的 Redux 代码,所以很高兴能少写一些代码。但这样的代码很容易变得一团糟。
Closing thoughts
All of this is a lot to take in. So if you take away only one thing from this post, let it be the totally not new idea of state op-free components, where components are put in charge of only rendering state values and emitting actions. The key is moving state operations out of the chaos of the render function and the useEffect
hook and onto stable ground. Here is how I think it will work for the libraries I tried.
所有这些都很难消化。所以如果你只能带走一点东西,那就带走这个完全不是什么新想法的 state op-free 组件的概念,即组件只负责渲染状态值并发出动作。关键在于将状态操作从渲染函数和 useEffect
钩子的混乱中移出,放到稳定的地方。这是我认为这些库将如何工作的想法。
- If you are able to use the query and mutation hooks without using any other hooks alongside them, then data fetching libraries (i.e. Tanstack query, RTK query) can allow you to build state op-free components. But I think you will probably need extra functionality on top of them and that is where things start to get ugly.
如果你能够不使用其他 hooks 的情况下使用查询和突变 hooks,那么数据获取库(例如 Tanstack 查询、RTK 查询)可以让你构建无状态操作的组件。但我认为你可能需要在此基础上添加额外的功能,而这时事情就开始变得复杂了。 - Using a state management library alongside a data fetching library will still introduce state-ops into the component. So when you need custom functionality, the only way to have state op-free components is to implement that functionality using a state management library.
使用状态管理库和数据获取库会仍然引入状态操作到组件中。所以当你需要自定义功能时,唯一的方法就是使用状态管理库来实现这些功能以避免状态操作。 - Most state management libraries don't have higher-level functionality built-in. So if you want to do something like data-fetching, you have to implement it from scratch. However, most state management libraries have built-in functionality for handling async operations so that will make it easier.
大多数状态管理库没有内置高级功能。所以如果你想进行数据获取之类的操作,你必须从头实现。然而,大多数状态管理库都内置了处理异步操作的功能,这样会更容易一些。 - While RTK query is built on Redux, the underlying API slices can't be modified to the same degree as standard RTK slices. While RTK is much nicer to work with than vanilla Redux, implementing async operations and side effects is still awkward with thunks and middleware.
虽然 RTK 查询基于 Redux 构建,但底层 API 切片不能像标准 RTK 切片那样进行相同的修改。尽管 RTK 比原生 Redux 更容易使用,但在 thunks 和中间件的帮助下实现异步操作和副作用仍然很笨拙。 - Newer versions of React router (v6+) have a very nice way of lifting server operations outside of the components in the form of loaders and actions. But they seem best suited to handling page-level operations.
newer 版本的 React 路由(v6+)有一种很好的方法,可以将服务器操作提升到组件之外,以加载器和动作的形式存在。但它们似乎最适合处理页面级别的操作。 - Each member of the Poimandres trio (Zustand, Jotai, Valtio) expose a way to work with state outside of React components. But you need to be proactive in implementing state-ops and side effects outside the components since it is easier and more obvious to implement them within.
Poimandres 三重奏中的每个成员(Zustand、Jotai、Valtio)展示了在 React 组件外部与状态交互的方法。但你需要积极地在组件外部实现状态操作和副作用,因为将它们放在组件内部更容易且更直观。 - Jotai has a growing ecosystem of extensions that allow higher-level functionality to be managed outside of the components. (e.g. jotai-tanstack-query, jotai-location) However, I had to poke around a lot to figure out how they work and how to use them well.
Jotai 有一个不断增长的扩展生态系统,允许在组件外部管理高级功能。(例如:jotai-tanstack-query, jotai-location)然而,我花了很长时间才弄清楚它们是如何工作的以及如何很好地使用它们。 - XState provides a great way to build state op-free components but writing statecharts takes a lot of effort. It might be worth it though.
XState 提供了一种 great 的方式来构建无状态操作的组件,但编写状态图需要大量的努力。不过这可能是值得的。
At the start of this post, I said state management is not a big deal since custom hooks manage it for us. But conversely, state management is more important now than ever when those hooks fall short.
在这篇文章开始时,我说状态管理不是什么大问题,因为自定义钩子帮我们管理状态。但相反,当这些钩子不起作用时,状态管理现在比以往任何时候都更重要。