关于 web-state-zustand
为使用 Zustand 的前端开发者提供状态管理模式,帮助判断状态应放在 Store、组件、URL 还是 Context。内容聚焦选择器、订阅范围及 v5 行为。
主要功能
- 01Store 与其他状态位置的选择
- 02原子选择器与浅比较
- 03使用 devtools 和 persist
- 04拆分聚焦型 Store
- 05URL 中保存可分享状态
适用场景
- 01在多个组件间共享 UI 状态
- 02限制状态变化引发的重渲染
- 03持久化用户偏好
- 04将筛选和分页放入 URL
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-state-zustand安装后在 Claude Code 中输入/web-state-zustand确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/core.md详细介绍
Zustand 模式
快速指南: 两个或更多组件读取的状态由 Zustand 管理;一个组件读取的状态由
useState管理;筛选条件和搜索内容由 URL 管理;Context 用于注入单例,不保存状态。Zustand v5 有三项变化:zustand/react/shallow中的useShallow取代旧版作为第二个参数的相等性函数;选择器必须返回稳定引用,否则会陷入循环;persist不再在创建时捕获初始状态。
详细资源:
- examples/core.md — store 设置、原子选择器、
useShallow、Context 反模式、URL 状态 - reference.md — 中间件查询、v5 迁移说明、对等依赖要求
<critical_requirements>
编写 Zustand 代码前
只要第二个组件要读取某个值,就立即将它移入 store。 将它逐层传递会让路径上的每个组件都与之耦合,而之后再移动它就意味着要拆解这条传递链。
每次调用 useStore 只选择一个值。 这样,组件仅在该值变化时重新渲染;不带选择器的调用会订阅整个 store。
每个选择器都要返回稳定引用。 在选择器内部创建的对象或函数,每次调用都会产生新引用;在 v5 中,这会导致无休止地重新渲染。
只通过 partialize 持久化偏好设置,不要持久化其他内容。 从存储中恢复的临时 UI 状态——例如会自动重新打开的模态框、会记住关闭状态的侧边栏——看起来就像是 bug。
不要将服务端数据放进 store。 缓存、失效和重新获取数据需要专门为此构建的层;store 只会给你一份悄然过时的数据副本。
</critical_requirements>
自动检测: create from zustand、zustand/middleware、zustand/react/shallow、useShallow、createWithEqualityFn、zustand/traditional、persist、partialize、devtools、store 切片
适用范围:
- 针对给定值,在 store、
useState、URL 和 Context 之间做出选择 - 使用
devtools和persist设置 store - 选择器结构与重新渲染行为
- 将状态拆分到各个专用 store 中
其他部分介绍:
- 服务端数据——缓存、失效和重新获取应由负责网络的部分处理,store 并不负责这些
- 路由机制——本指南说明哪些状态应该放在 URL 中,不涉及特定路由器如何读取或写入这些状态
- 表单字段状态——表单打开时,表单库负责管理自身的字段值
Zustand 是一种附带 hook 的订阅原语。它的性能完全取决于选择器:store 每次变化都会通知所有订阅者,而选择器决定这次通知是否会触发渲染。因此,store 设计就是选择器设计。
- 优先选择小型 store,而不是一个大型 store——store 既是订阅单位,也是代码单位
- 将业务逻辑放在 action 中——组件调用
toggleSidebar();store 决定切换意味着什么 - 导出 hook,而不是创建器——原始 store 是可变的全局对象,导出它会引来来自任何地方的写入
- 优先使用原子选择器——单个值完全不需要比较函数
<decision_framework>
值应该放在哪里
| 值 | 应该放在 | 原因 |
|---|---|---|
| 被 2 个或更多组件读取 | store | 可选择性重新渲染,而且以后无需理清层层传递的 props |
| 被 1 个组件读取 | useState | 没有其他对象能观察到它,因此也不需要观察它 |
| 筛选条件、查询、页面或排序 | URL | 可分享、可收藏为书签,而且后退按钮可以正常工作 |
| 启动时设置的单例 | Context | 它永远不会变化,因此不会产生重新渲染开销 |
| 从 API 获取 | 不属于此技能 | 它需要缓存和失效机制,而 store 两者都没有 |
Context 是人们最容易用错的一项。它用于传递不会变化的值;如果用它管理会变化的状态,每次变化都会重新渲染所有消费者,而且无法选择退出。
</decision_framework>
核心模式
模式 1:Store 设置
使用 devtools 进行检查,使用 persist 持久化那些需要在标签页关闭后保留的字段,并用 partialize 将其他内容排除在存储之外。
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
theme: DEFAULT_THEME,
sidebarOpen: true,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}),
{ name: UI_STORAGE_KEY, partialize: (state) => ({ theme: state.theme }) },
),
),
);
完整代码:examples/core.md
模式 2:原子选择器
每次调用只选取一个值是默认做法,也是 store 保持快速的原因。
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isOpen = useUIStore((state) => state.sidebarOpen);
完整代码:examples/core.md
模式 3:使用 useShallow 选取多个值
从同一个 store 选取 3 个或更多值时,一次 useShallow 调用胜过 3 个订阅。
import { useShallow } from "zustand/react/shallow";
const { sidebarOpen, theme } = useUIStore(
useShallow((state) => ({
sidebarOpen: state.sidebarOpen,
theme: state.theme,
})),
);
完整代码:examples/core.md
模式 4:Context 用于注入,而非状态管理
Context 用于传递永远不会变化的值——客户端、连接,或启动时读取的配置。
const DatabaseContext = createContext<Database | null>(null);
完整代码:examples/core.md
模式 5:将可分享的筛选条件保存在 URL 中
筛选条件、搜索、分页和排序都放在 URL 中,这样重新加载页面或将链接粘贴到同事的聊天窗口后,它们仍然存在。
const searchParams = new URLSearchParams(window.location.search);
const category = searchParams.get("category") ?? undefined;
完整代码:examples/core.md
<red_flags>
注意事项
会在运行时出错:
- 选择器返回内联对象或函数——每次调用都会产生新的引用,因此 v5 会不停地重新渲染。将回退值提取为模块常量。
- 使用第二个相等函数参数调用
create()——该参数已在 v5 中移除,因此会被忽略,比较也会悄无声息地失效。请使用useShallow,或使用zustand/traditional中的createWithEqualityFn。 - 创建持久化 store 后立即读取其中计算得出的初始值——v5 的
persist不会存储该值。创建后使用setState设置它。
行为可能出乎意料:
- 不带选择器调用
useStore()会订阅整个 store,因此组件会因其从未读取的字段发生变化而重新渲染。 - 两个返回相同值的原子选择器仍会分别触发渲染,只要其中一个发生变化;
useShallow比较的是对象,而不是各个单独读取的值。 - 每当重建 provider 的值对象时,Context 都会重新渲染所有消费者;除非对该值进行记忆化处理,否则每次渲染都会重建。即使进行了记忆化,只要其中一个字段变化,读取所有字段的组件仍会重新渲染。
- 搜索参数都是字符串。
?page=2读取后是"2",而?open=false在解析之前仍会被视为真值。 - 一个大型 store 意味着庞大的订阅范围:每个从中读取数据的组件都会与每次写入竞争。
</red_flags>