这是用户在 2024-8-4 23:17 为 https://tkdodo.eu/blog/working-with-zustand 保存的双语快照页面,由 沉浸式翻译 提供双语支持。了解如何保存?
Skip to content
TkDodo's blog

Working with Zustand 与 Zustand 合作

ReactJs, State Management, Zustand4 min read
2022 年 11 月 20 日 — ReactJs,状态管理,状态 — 阅读 4 分钟

The Zustand bear logo
Source: Zustand GitHub Repository
来源:State GitHub 存储库

Global (client) state management wasn't always like it is today. I distinctly remember a time when our best option was Redux with higher order components using connect plus mapStateToProps and mapDispatchToProps.
全局(客户端)状态管理并不总是像今天这样。我清楚地记得有一次,我们最好的选择是 Redux 以及使用 connect 加上 mapStateToPropsmapDispatchToProps 的高阶组件。

Even the context api, initially, wasn't as ergonomic to use (pun intended), as it only supported render props when it came out.
即使是 context api,最初使用起来也不符合人体工程学(双关语),因为它刚推出时只支持渲染道具。

Of course, everything changed when hooks were released. Not only did existing solutions become much easier to use, but new ones were born.
当然,当钩子被释放后,一切都改变了。现有的解决方案不仅变得更容易使用,而且新的解决方案也诞生了。

Zustand 祖斯坦

One of them that I quickly grew to like was Zustand. It's a tiny library (v4.1.4 is 1.1kB Minified + Gzipped) that provides a simple API to create global state stores and subscribe to them via selectors. This pattern is conceptually similar to what Redux is doing, which is already known by many developers.
Zustand 是我很快就喜欢上的其中之一。它是一个小型库(v4.1.4 是 1.1kB 压缩+压缩),提供了一个简单的 API 来创建全局状态存储并通过选择器订阅它们。这种模式在概念上类似于 Redux 正在做的事情,这已经被许多开发人员所熟知。

Much like React itself, Zustand is not opinionated. You can combine it with immer if you want to. You can dispatch actions, but you don't have to. It has powerful middlewares, but they are totally optional.
就像 React 本身一样,Zustand 并不固执己见。如果您愿意,您可以将其与沉浸式设备结合使用。您可以分派操作,但不是必须的。它有强大的中间件,但它们是完全可选的。

I do like that about the library. It provides the bear minimum to get you started (hence the logo), and it's flexible enough to scale to your needs. However, it does leave you with a bunch of decisions to make yourself - analogous to how React doesn't prescribe a way to do styling.
我确实喜欢图书馆这一点。它提供了入门所需的最低限度(因此有了徽标),并且足够灵活,可以根据您的需求进行扩展。然而,它确实给你留下了一堆需要你自己做出的决定——类似于 React 没有规定一种样式设计方法。

It's also not "magical". It doesn't track which fields you are using, as some similar libraries do; you have to subscribe "manually" with selectors. And in my experience, that's a good thing because it enforces being very explicit about your dependencies, which pays off as an app grows, even though it might be a little bit more to write.
它也并不“神奇”。它不会像一些类似的库那样跟踪您正在使用哪些字段;您必须使用选择器“手动”订阅。根据我的经验,这是一件好事,因为它强制要求您非常明确地了解您的依赖关系,这会随着应用程序的增长而得到回报,尽管它可能需要编写更多的内容。

I've been working with Zustand since 2018, in projects small and large. I've also contributed a bit to the library. Here are a couple of tips I've picked up along the way:
自 2018 年以来,我一直与 Zustand 合作,参与过大大小小的项目。我也为图书馆做出了一些贡献。以下是我在此过程中学到的一些技巧:

Only export custom hooks 只导出自定义钩子

This is my number one tip for working with... everything in React, really. I've listed many advantages of custom hooks before, and I believe they apply to working with Zustand as well.
这是我处理 React 中所有内容的第一个技巧,真的。我之前已经列出了自定义 Hook 的许多优点,我相信它们也适用于与 Zustand 一起使用。

custom-hooks 定制挂钩
1// ⬇️ not exported, so that no one can subscribe to the entire store
2const useBearStore = create((set) => ({
3 bears: 0,
4 fish: 0,
5 increasePopulation: (by) =>
6 set((state) => ({ bears: state.bears + by })),
7 eatFish: () => set((state) => ({ fish: state.fish - 1 })),
8 removeAllBears: () => set({ bears: 0 }),
9}))
10
11// 💡 exported - consumers don't need to write selectors
12export const useBears = () => useBearStore((state) => state.bears)

They'll give you a cleaner interface, and you don't need to write the selector repeatedly everywhere you want to subscribe to just one value in the store. Also, it avoids accidentally subscribing to the entire store:
它们将为您提供一个更清晰的界面,并且您无需在想要订阅商店中的一个值的任何地方重复编写选择器。此外,它还可以避免意外订阅整个商店:

subscribe-to-the-entire-store
订阅整个商店
1// ❌ we could do this if useBearStore was exported
2const { bears } = useBearStore()

While the result might be the same, you'll get the number of bears, the code above will subscribe you to the entire store, which means that your component will be informed about any state update, and therefore re-rendered, even if bears did not change, e.g. because someone ate a fish.
虽然结果可能是相同的,但您将获得熊的数量,上面的代码将为您订阅整个商店,这意味着您的组件将被告知任何状态更新,因此会重新渲染,即使熊没有改变,例如因为有人吃了鱼。

While selectors are optional in Zustand, I think they should always be used. Even if we have a store with just a single state value, I'd write a custom hook solely to be able to add more state in the future.
虽然选择器在 Zustand 中是可选的,但我认为应该始终使用它们。即使我们的商店只有一个状态值,我也会编写一个自定义挂钩,以便将来能够添加更多状态。

Prefer atomic selectors 更喜欢原子选择器

This is already explained in the docs, so I'll keep it brief, but it's still quite important because it can lead to degraded rendering performance if you "get it wrong".
这已经在文档中进行了解释,所以我将保持简短,但它仍然非常重要,因为如果你“弄错了”,它可能会导致渲染性能下降。

Zustand decides when to inform your component that the state it is interested in has changed, by comparing the result of the selector with the result of the previous render. Per default, it does so with a strict equality check.
Zustand 通过将选择器的结果与先前渲染的结果进行比较来决定何时通知您的组件它感兴趣的状态已更改。默认情况下,它会通过严格的相等检查来实现这一点。

Effectively, this means that selectors have to return stable results. If you return a new Array or Object, it will always be considered a change, even if the content is the same:
实际上,这意味着选择器必须返回稳定的结果。如果返回一个新的数组或对象,即使内容相同,它也将始终被视为更改:

ineffective-selectors 无效选择器
1// 🚨 selector returns a new Object in every invocation
2const { bears, fish } = useBearStore((state) => ({
3 bears: state.bears,
4 fish: state.fish,
5}))
6
7// 😮 so these two are equivalent
8const { bears, fish } = useBearStore()

If you want to return an Object or Array from a selector, you can adjust the comparison function to use shallow comparison:
如果要从选择器返回对象或数组,可以调整比较函数以使用浅比较:

shallow-comparison 浅层比较
1import shallow from 'zustand/shallow'
2
3// ⬇️ much better, because optimized
4const { bears, fish } = useBearStore(
5 (state) => ({ bears: state.bears, fish: state.fish }),
6 shallow
7)

However, I much prefer the simplicity of just exporting two separate selectors:
但是,我更喜欢仅导出两个单独的选择器的简单性:

atomic-selectors 原子选择器
1export const useBears = () => useBearStore((state) => state.bears)
2export const useFish = () => useBearStore((state) => state.fish)

If a component really needs both values, it can consume both hooks.
如果组件确实需要这两个值,则它可以使用两个钩子。

Separate Actions from State
与国家分开的行动

Actions are functions which update values in your store. These are static and never change, so they aren't technically "state". Organising them into a separate object in our store will allow us to expose them as a single hook to be used in any our components without any impact on performance:
操作是更新商店中的值的函数。它们是静态的并且永远不会改变,因此从技术上讲它们不是“状态”。将它们组织到我们商店中的单独对象中将使我们能够将它们公开为单个钩子,以便在我们的任何组件中使用,而不会对性能产生任何影响:

separate-actions 单独的动作
1const useBearStore = create((set) => ({
2 bears: 0,
3 fish: 0,
4 // ⬇️ separate "namespace" for actions
5 actions: {
6 increasePopulation: (by) =>
7 set((state) => ({ bears: state.bears + by })),
8 eatFish: () => set((state) => ({ fish: state.fish - 1 })),
9 removeAllBears: () => set({ bears: 0 }),
10 },
11}))
12
13export const useBears = () => useBearStore((state) => state.bears)
14export const useFish = () => useBearStore((state) => state.fish)
15
16// 🎉 one selector for all our actions
17export const useBearActions = () =>
18 useBearStore((state) => state.actions)

Note that it's totally fine to now destruct actions and only "use" one of them:
请注意,现在破坏操作并仅“使用”其中之一是完全可以的:

useBearActions 使用BearActions
1const { increasePopulation } = useBearActions()

This might sound contradictory to the "atomic selectors" tip above, but it really isn't. As actions never change, it doesn't matter that we subscribe to "all of them". The actions object can be seen as a single atomic piece.
这听起来可能与上面的“原子选择器”提示相矛盾,但事实并非如此。由于行动永远不会改变,因此我们订阅“所有行动”并不重要。 actions 对象可以被视为一个原子片段。

Model Actions as Events, not Setters
将操作建模为事件,而不是设置器

This is a general tip, no matter if you're working with useReducer, Redux or Zustand. In fact, this tip is straight from the magnificent Redux style guide. It will help you keep your business logic inside your store, and not in your components.
无论您使用的是 useReducer、Redux 还是 Zustand,这都是一般性提示。事实上,这个技巧直接来自精彩的 Redux 风格指南。它将帮助您将业务逻辑保留在商店中,而不是组件中。

The examples above have already been using this pattern - the logic (e.g. "increase population") lives in the store. The component just calls the action, and the store decides what to do with it.
上面的例子已经使用了这种模式 - 逻辑(例如“增加人口”)存在于商店中。组件只是调用操作,存储决定如何处理它。

Keep the scope of your store small
保持商店范围较小

Unlike Redux, where you're supposed to have a single store for your whole app, Zustand encourages you to have multiple, small stores. Each store can be responsible for a single piece of state. If you need to combine them, you can do that with - of course - custom hooks:
与 Redux 不同,在 Redux 中,您的整个应用程序应该有一个商店,Zustand 鼓励您拥有多个小型商店。每个存储可以负责一个状态。如果您需要组合它们,当然可以使用自定义挂钩来做到这一点:

combine-stores 联合商店
1const currentUser = useCredentialsStore((state) => state.currentUser)
2const user = useUsersStore((state) => state.users[currentUser])

Note: Zustand does have another way to combine stores into slices, but I've never needed that. It doesn't look super straightforward to me, especially when TypeScript is involved. If I specifically needed that, I would rather opt for Redux Toolkit.
注意:Zustand 确实有另一种方法将商店组合成切片,但我从来不需要它。对我来说,它看起来并不那么简单,尤其是当涉及 TypeScript 时。如果我特别需要,我宁愿选择 Redux Toolkit。

Combinations with other libraries
与其他库的组合

I honestly haven't needed to combine multiple Zustand stores very often, because most of the state in apps is either server or url state. I'm far more likely to combine a Zustand store with useQuery or useParams, for example, than I am to combine two separate stores.
老实说,我并不需要经常组合多个 Zustand 商店,因为应用程序中的大多数状态要么是服务器状态,要么是 url 状态。例如,我更有可能将 Zustand 商店与 useQueryuseParams 组合,而不是组合两个单独的商店。

Once again, the same principle applies: if you need to combine another hook with a Zustand store, custom hooks are your best friend:
同样的原则同样适用:如果您需要将另一个钩子与 Zustand 商店结合起来,自定义钩子是您最好的朋友:

combine-with-useQuery 与 useQuery 结合
1const useFilterStore = create((set) => ({
2 applied: [],
3 actions: {
4 addFilter: (filter) =>
5 set((state) => ({ applied: [...state.applied, filter] })),
6 },
7}))
8
9export const useAppliedFilters = () =>
10 useFilterStore((state) => state.applied)
11
12export const useFiltersActions = () =>
13 useFilterStore((state) => state.actions)
14
15// 🚀 combine the zustand store with a query
16export const useFilteredTodos = () => {
17 const filters = useAppliedFilters()
18 return useQuery({
19 queryKey: ['todos', filters],
20 queryFn: () => getTodos(filters),
21 })
22}

Here, the applied filters drive the query, because they are part of the query key. Every time you call addFilter, which you can do from anywhere in your UI, you will automatically trigger a new query, which could also be used from anywhere in your UI. I find this to be pretty declarative and minimal, without being too magical.
在这里,应用的过滤器驱动查询,因为它们是查询键的一部分。每次调用 addFilter 时(您可以在 UI 中的任何位置执行此操作),您都会自动触发一个新查询,该查询也可以在 UI 中的任何位置使用。我发现这是相当声明性和简约的,但又不太神奇。


That's it for today. Feel free to reach out to me on twitter if you have any questions, or just leave a comment below. ⬇️
今天就这样。如果您有任何疑问,请随时在 Twitter 上与我联系,或者在下面发表评论。 ⬇️

Like the monospace font in the code blocks?
喜欢代码块中的等宽字体吗?
Check out monolisa.dev 查看 monolisa.dev
Bytes - the JavaScript Newsletter that doesn't suck