这是用户在 2024-12-17 24:11 为 https://www.frontendundefined.com/posts/monthly/react-state-management-reflections/ 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?

UPDATE: This post was easily misunderstood. Let me offer a few clarifications.
更新:这篇文章容易被误解。让我提供一些澄清。

  1. 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 管理的理解是如何变化的。
  2. 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.
    最初,这篇文章中有一些陈述错误地暗示某些库在某些用例中是无法使用的。我已经删除了这些有误的陈述,因为它们对本文并不重要。
  3. 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 avoid useState, 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.
那有很多库,在这篇文章中,我会描述在使用了所有这些库之后我对管理状态的一些认识。

Hand-drawn sketch of a cogwheel named state instead a thought cloud

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 应用的时代,状态管理库是核心,并且影响了整个应用的结构。

Hand-drawn sketch of the Redux logo with a crown on it

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 应用的大部分功能。甚至你可以完全跳过状态管理库,因为只需要管理少量的状态。

Hand-drawn sketch of an illustration of data fetching libraries with a crown on it

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

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,这种行为的一部分可以通过自定义 Tanstack 查询钩子来实现。但对于其余部分,你就没运气了,你将被迫在这些钩子旁边使用额外的 useState 或 useEffect 钩子来实现更多自定义行为。在过去,这种自定义逻辑是在状态管理库构造中(例如,reducer)。但现在它在渲染函数中,这对它们来说是个糟糕的地方。说到这一点,

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 挂钩增多,它变得越来越难以理解,并且更容易出错。我认为这有几个原因。

// 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 时,我给自己设定了一个目标,即完全不使用 useStateuseEffect 钩子。令我惊讶的是,我成功了,这是我感觉使用 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 钩子的混乱中移出,放到稳定的地方。这是我认为这些库将如何工作的想法。

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.
在这篇文章开始时,我说状态管理不是什么大问题,因为自定义钩子帮我们管理状态。但相反,当这些钩子不起作用时,状态管理现在比以往任何时候都更重要。

Hand-drawn sketch of an illustration of state with a crown on it
Prabashwara Seneviratne (bash)

Written by

Prabashwara Seneviratne (bash)

Lead frontend developer 前端开发负责人