实际应用状况:小型店铺、精挑细选的采购者与植物追踪系统
用 create()、selectors、异步 action、persist/devtools 中间件、slices 以及 hydration plant 应用来替代 prop-drilling 和 Redux 流程。
这是一篇适合初学者的 Zustand 教程——这款每周下载量达数千万次的 React 状态管理库,同时还包含一个从零到尾实现的植物养护示例。
当终于有人告诉你有更好的方法时
Zustand 出现之前:React 实际上是如何工作的
网页最初只是 HTML 标签——如 div、button、h1。为大型产品手动编辑这些标记非常繁琐:每次数据变化都需要查找对应元素并重新编写代码。登录页面需要修改?就要修改十几处;购物车内容增加?又得修改另外十几处。
React 改变了这种模式。组件是描述基于当前数据界面的函数,由库来决定在 DOM 中进行哪些修改。开发者负责描述界面,React 负责更新。
一个简单的组件看起来像这样:
function WelcomeBanner() {
return <h1>Hello, stranger</h1>
}
该函数返回的是 JSX——一种类似 HTML 的语法,由 React 编译处理,而非真正的 HTML 代码。
后续章节用到的简要术语表:
- JavaScript —— 支持
console.log和const x = 5的编程语言。 - npm —— 包管理工具。
npm install zustand会将 Zustand 下载到项目中。 - Hooks —— 名称以
use开头的函数(如useState、useEffect以及自定义存储 Hook)。它们是 React 中处理数据与副作用的现代方式。
只要理解了这三个概念,就能更好地掌握本指南的其余内容。
React 就像厨师,你需要把食谱和食材交给他。
“状态”真正的含义
状态指的是界面必须记住的任何信息:是否已登录、购物车中的物品数量、侧边栏是否展开、头像的URL地址,以及搜索框中的文本。普通的变量无法自动刷新页面。React钩子既可以存储值,又能在这些值发生变化时安排重新渲染。
最简单的钩子是useState:
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
// Creates a state variable called count, starting at 0.
// setCount is the only way to change it.
// Every time setCount runs, React re-renders this component.
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}
当某个组件独自管理某项状态时,本地状态非常有用。但问题在于,当不同的组件需要共享同一信息——比如登录表单、页头头像、控制面板欢迎语、设置中的退出功能——而它们在组件树中又并非相邻时,就会出现问题。
单独的useState调用之间无法互相通信。如何在距离较远的界面之间共享状态才是真正的难题。
各个独立的状态彼此无法交互。问题一目了然。
Prop传递带来的困扰
React 的首个解决方案是“向上提升状态”:将共享数据放在最近的公共祖先节点上,并通过属性(函数参数)将其向下传递。
这种方法适用于结构较简单的组件树。但当 user 数据需要经过 App → Layout → MainContent → Dashboard → Header → ProfilePic 这样多层传递时,问题就出现了。中间层从不使用这些数据,只是负责转发而已。主题设置、购物车功能以及身份验证令牌又增加了额外的传递环节,且这些中间节点对数据并无实际用途。代码重构还可能破坏那些与之无关的文件。
Context 模块旨在终结这种层层传递的模式。Provider 会包裹整个组件树,子组件则通过 useContext 访问上下文数据。但问题在于:只要上下文数据的任何部分发生变化,相关组件就会重新渲染,这对于需要频繁更新状态的场景来说十分不利。
Redux(2015年)通过可预测的外部状态以及仅重新渲染发生变化部分的选择器来解决问题——但同时也引入了许多团队厌倦维护的繁琐流程。
熊的出现
Zustand源自Paul Henschel的Poimandres团队(他们也是React Three Fiber和Jotai的开发者)。其名称在德语中意为“状态”。项目配有熊形吉祥物。目前它在GitHub上的星标数已达数万,每周的npm下载量约为2000万次——在最近几周里这一数字已经超过了传统Redux与Redux Toolkit之和。
整个核心API仅包含一个函数create。传入状态定义和操作函数,即可获得一个React钩子。无需Provider,没有操作类型常量,也没有独立的reducer文件。示例如下:
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
任何组件都可以调用该钩子,无需中间传递环节。
每个组件都能直接与存储交互,不需要任何中间传递过程。
思维模型:状态存储相当于一个所有人都能阅读和编辑的固定群聊消息。Redux 更像是法院——提交申请(动作),等待还原器的判决,再通过受控的渠道查看结果。Redux 用繁琐的流程换取可预测性;而 Zustand 则依靠有序的更新,在减少文书工作的同时保持稳定性。
第一个真正的状态存储
搭建一个 Vite React 应用并安装相关库:
npm create vite@latest my-first-zustand -- --template react
cd my-first-zustand
npm install
npm install zustand
npm run dev
创建 useCounterStore.js 文件:
// useCounterStore.js
import { create } from 'zustand'
// We're borrowing one function from the zustand package.
// That's all we need. Zustand doesn't hide other stuff from us, there genuinely isn't more.
const useCounterStore = create((set) => ({
// create takes a function as its only argument.
// That function receives two tools named set and get.
// We only need set for now. It's how we change state.
count: 0,
// This is a state field. It lives in the store.
// Any component in our app can read it.
increment: () =>
set((state) => ({ count: state.count + 1 })),
// This is an action. Actions are just functions that call set.
// We pass set a function that takes the OLD state and returns
// an object describing what should change.
// Zustand merges this object into the store for us.
decrement: () =>
set((state) => ({ count: state.count - 1 })),
// Another action. Same pattern. Decrement by one.
reset: () => set({ count: 0 }),
// When we don't need the old state, we can just pass a plain object.
// Zustand handles both forms.
}))
export default useCounterStore
// Ship the hook out so other files can import it.
使用它:
// Counter.jsx
import useCounterStore from './useCounterStore'
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)
// Each line is a subscription.
// We grab exactly what we need and no more.
// This matters for performance, we'll get to why.
return (
<div>
<h1>Count {count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>reset</button>
</div>
)
}
export default Counter
在任何地方渲染 <Counter />,数值就会发生变化。注意其中缺失了什么:Provider 包装、上下文初始化、动作常量、还原器、connect、mapDispatchToProps 以及 thunk 配置。仅需一次 create 调用和一个钩子即可。
“等等,真的就这样?”
底层原理
create会在模块作用域内创建一个用于存储状态的普通对象以及一个空的监听器列表(模块加载后立即成为单例)。
当组件使用该钩子时:
- Zustand会用当前状态执行选择器(例如
(state) => state.count),并返回对应的值。 - 它会将组件及选择器注册为监听器。在后续更新时,它会重新执行每个选择器,仅当选中的值发生变化时才会重新渲染。
在核心执行路径中不存在React Context——它更像是带有React接口的微型事件发射器。由于状态存储位于React树之外,同一模块的所有导入都会共享同一个实例。只有在极少数需要多实例的场景下才会使用传统的多实例存储方式;大多数应用其实并不需要这种替代方案。
整个生命周期包含四个步骤和一个反馈循环
选择器(切勿跳过)
选择器决定了组件何时重新渲染。
模式A — 整个状态存储(通常错误):
const store = useCounterStore()
// No selector. Zustand returns the entire store object.
// Your component now re-renders on ANY state change, even unrelated ones.
// If someone else changes a different piece of state you don't care about,
// this component still wastes a render cycle.
模式B — 单个字段(通常正确):
const count = useCounterStore((state) => state.count)
// Now your component only re-renders when count changes specifically.
// Other state can update freely. This component sleeps through it.
模式C — 对象字面量陷阱:
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}))
// Looks clean. It's a trap.
// This selector returns a NEW object every time it runs.
// Zustand compares the new object to the old object. They're different objects.
// Result: this component re-renders on every state change in the entire store.
// Worse than pattern A for obvious reasons.
每次调用都会生成一个新对象,即使字段没有变化,也会显得“更新”,从而导致频繁重新渲染。
左侧为糟糕的选择器,右侧为良好的选择器。在大型应用中这种差异极为明显。
需要多个字段且不想创建新对象?请使用useShallow:
import { useShallow } from 'zustand/react/shallow'
const { count, increment } = useCounterStore(
useShallow((state) => ({
count: state.count,
increment: state.increment,
}))
)
// useShallow tells Zustand "compare the returned object field by field."
// If count and increment didn't change individually, no re-render.
// Now you get clean destructuring AND performance.
许多实际项目更倾向于多次调用较窄范围的钩子函数。经验法则:只获取最小范围的数据;不确定时,拆分钩子而非合并它们。
操作:同步与异步
操作与状态存在于同一个对象中。同步更新使用set:
const useCartStore = create((set, get) => ({
items: [],
addItem: (product) =>
set((state) => ({
items: [...state.items, product],
})),
// Spread the existing items into a new array.
// Add the new product at the end.
// Return the updated items array to merge back into state.
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.id !== productId),
})),
// Filter out the item with the matching id.
// Return the filtered array.
clear: () => set({ items: [] }),
// Reset to empty. No need to read old state.
}))
get 可以在无需注册 React 监听器的情况下读取动作中的当前状态——非常适合用于决策:
const useWalletStore = create((set, get) => ({
balance: 100,
tryWithdraw: (amount) => {
const currentBalance = get().balance
// Read the current balance at this exact moment.
// No subscription. No re-render. Just a fresh read.
if (currentBalance < amount) {
return { success: false, message: 'Not enough funds' }
// Bail out without touching state.
}
set((state) => ({ balance: state.balance - amount }))
return { success: true, message: 'Withdrawn successfully' }
},
}))
异步操作只需使用普通的 async/await 即可,无需繁琐的 thunk 中间件流程:
const usePostStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null })
// Flip loading to true. Clear any previous errors.
// Components showing a spinner will now show it.
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
const data = await response.json()
// Hit the API. Wait for it. Parse the JSON.
set({ posts: data, loading: false })
// Store the posts. Flip loading back to false.
// Components showing the list now have data.
} catch (err) {
set({ error: err.message, loading: false })
// If anything exploded, record the error message.
// Components can now show an error banner.
}
},
}))
正是没有这些冗余的管道代码,才使其与传统的 Redux 异步配置形成实际差异。
中间件:多层功能
中间件用于封装 store 的创建逻辑,常见的中间件包括以下几类。
持久化——防止页面刷新后数据丢失
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggle: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{
name: 'theme-storage',
// The key under which Zustand saves your state in localStorage.
// Name it whatever you want, just make it unique.
}
)
)
刷新页面后主题信息(或您选择的其它字段)仍会保留。在开发者工具的“应用程序”→“本地存储”中可以看到对应的 JSON 数据。
开发者工具——查看动作信息
尽管名为 Redux,但当 store 被 devtools 封装时,该浏览器扩展依然可以正常使用:
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(s) => ({ count: s.count + 1 }),
false,
'counter/increment'
),
// The third argument is the action name shown in DevTools.
// If you skip it, you'll see "anonymous" for every action, which is useless for debugging.
}),
{ name: 'CounterStore' }
)
)
Immer——实现嵌套更新而无需使用嵌套展开运算符
痛苦的不可变嵌套数据结构:
// Without immer, updating a deeply nested field:
updateCity: (city) => set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city,
},
},
},
}))
Immer总让更新看起来像是可变的,但实际上底层仍是不可变的:
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: { profile: { address: { city: '' } } },
updateCity: (city) =>
set((state) => {
state.user.profile.address.city = city
// Looks like mutation. Isn't actually mutation.
// Immer tracks the change and produces a new immutable state object.
}),
}))
)
支持选择器的外部监听器
对于那些仅应在选中的数据片段发生变化时才触发的非React监听器,Zustand提供了subscribeWithSelector中间件:
import { subscribeWithSelector } from 'zustand/middleware'
const useCartStore = create(
subscribeWithSelector((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}))
)
// Somewhere outside React, like in an analytics file:
useCartStore.subscribe(
(state) => state.items,
// The selector. We only care about items.
(items, previousItems) => {
console.log('Cart changed', { from: previousItems, to: items })
// This runs every time items changes, with both old and new values.
// No React component involved. No re-render. Just a side effect.
}
)
多层嵌套结构
const useStore = create(
persist(
devtools(
subscribeWithSelector(
immer((set, get) => ({
// your store definition
}))
),
{ name: 'MyStore' }
),
{ name: 'my-store-storage' }
)
)
一开始很复杂,之后就被忽视了。
中间件就像洋葱一样层层嵌套,每一层都增加一种功能。
可扩展的数据片段
单个文件尚可,但当购物车、认证、主题和通知等功能相互冲突时就会出现问题。数据片段工厂能够在构建单一存储的同时保持各功能域的独立性:
// cartSlice.js
export const createCartSlice = (set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
clearCart: () => set({ items: [] }),
})
// authSlice.js
export const createAuthSlice = (set, get) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
})
// useAppStore.js
import { create } from 'zustand'
import { createCartSlice } from './cartSlice'
import { createAuthSlice } from './authSlice'
const useAppStore = create((set, get) => ({
...createCartSlice(set, get),
...createAuthSlice(set, get),
}))
组件仍然可以自行选择所需的数据。一旦涉及大约五个以上的功能域,使用数据片段就会带来更多复杂性;在此之前,单个文件更为清晰。
无需渲染UI即可测试
存储只是普通的模块,只需重置状态、调用操作函数并进行断言即可:
// useCounterStore.test.js
import useCounterStore from './useCounterStore'
describe('counter store', () => {
beforeEach(() => {
useCounterStore.setState({ count: 0 })
// Reset state before each test.
// setState is exposed on the hook itself, not just for components.
})
it('increments count', () => {
useCounterStore.getState().increment()
// getState gives you the current store object outside of React.
// .increment() calls the action.
expect(useCounterStore.getState().count).toBe(1)
// Verify the count went from 0 to 1.
})
it('resets count', () => {
useCounterStore.getState().increment()
useCounterStore.getState().increment()
useCounterStore.getState().reset()
expect(useCounterStore.getState().count).toBe(0)
})
})
没有渲染器,也没有模拟的Provider——单元测试因而既快速又可靠。
项目:植物浇水记录器
构建一个用于记录植物养护情况的工具:可添加带有浇水频率的植物、标记已浇水的状态、突出显示逾期未浇水的植物、展示统计数据,并在页面刷新后保持数据不变。功能包括:
- 添加植物(名称+下次浇水间隔天数)
- 列出所有植物
- 一键浇水
- 标记缺水的植物
- 删除植物
- 统计面板
- 页面刷新后数据依然保留
整个应用仅包含一个页面,四个组件,一个数据存储,让植物们快乐成长。
文件1 — 数据存储
src/store/usePlantStore.js:
// src/store/usePlantStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Helper function. Not exported. Just used internally.
// Given a plant object, returns true if it needs water.
const isThirsty = (plant) => {
const msPerDay = 1000 * 60 * 60 * 24
// milliseconds in a second times seconds in a minute
// times minutes in an hour times hours in a day
const daysSinceWatering = (Date.now() - plant.lastWatered) / msPerDay
return daysSinceWatering >= plant.frequencyDays
}
const usePlantStore = create(
persist(
(set, get) => ({
plants: [],
// The big array that holds every plant.
// Each plant will be an object with id, name, frequencyDays, lastWatered.
addPlant: (name, frequencyDays) =>
set((state) => ({
plants: [
...state.plants,
{
id: Date.now() + Math.random(),
// Quick unique id. Good enough for a personal app.
// For production use nanoid or uuid from npm.
name,
frequencyDays,
lastWatered: Date.now(),
// New plants count as freshly watered.
// Otherwise they'd show as thirsty the second they're added, which is mean.
},
],
})),
waterPlant: (id) =>
set((state) => ({
plants: state.plants.map((plant) =>
plant.id === id
? { ...plant, lastWatered: Date.now() }
: plant
),
// Find the matching plant, return a new object with updated timestamp.
// Leave all other plants untouched.
})),
removePlant: (id) =>
set((state) => ({
plants: state.plants.filter((plant) => plant.id !== id),
// Drop the matching plant. Keep everyone else.
})),
// These are getter-style helpers using get().
// They're not stored, they're computed from current state.
thirstyCount: () => get().plants.filter(isThirsty).length,
happyCount: () => get().plants.filter((p) => !isThirsty(p)).length,
isPlantThirsty: (id) => {
const plant = get().plants.find((p) => p.id === id)
return plant ? isThirsty(plant) : false
},
}),
{
name: 'plant-hydration-v1',
// localStorage key. Prefixing with v1 lets me change schema later
// without breaking existing users' data.
}
)
)
export default usePlantStore
文件2 — 添加表单
// src/components/AddPlantForm.jsx
import { useState } from 'react'
import usePlantStore from '../store/usePlantStore'
function AddPlantForm() {
const [name, setName] = useState('')
const [frequency, setFrequency] = useState(3)
// These are local to this component.
// Form inputs are textbook useState territory.
// They don't need to be global.
const addPlant = usePlantStore((state) => state.addPlant)
// Only grab the action we need.
// We don't care about the plants array here, we don't pull it.
const handleSubmit = (event) => {
event.preventDefault()
// Prevent the default form submission that reloads the page.
// Modern React always wants this call on form events.
const trimmed = name.trim()
if (!trimmed) return
// Reject empty or whitespace-only names silently.
addPlant(trimmed, Number(frequency))
// Call the store action.
// Number() converts the string from the input into a number.
setName('')
setFrequency(3)
// Clear the form so the user can add another plant easily.
}
return (
<form onSubmit={handleSubmit} className="add-plant-form">
<h2>Add a Plant</h2>
<label className="field">
<span>Plant name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Monstera, Pothos, Something Latin"
/>
</label>
<label className="field">
<span>Water every</span>
<div className="freq-input">
<input
type="number"
min="1"
max="60"
value={frequency}
onChange={(event) => setFrequency(event.target.value)}
/>
<span>days</span>
</div>
</label>
<button type="submit">Add Plant</button>
</form>
)
}
export default AddPlantForm
文件3 — 列表展示
// src/components/PlantList.jsx
import usePlantStore from '../store/usePlantStore'
function PlantList() {
const plants = usePlantStore((state) => state.plants)
const waterPlant = usePlantStore((state) => state.waterPlant)
const removePlant = usePlantStore((state) => state.removePlant)
// Three separate subscriptions.
// Clean. Performant. Obvious.
if (plants.length === 0) {
return (
<div className="empty-state">
<p>No plants yet. Add one to start tracking.</p>
</div>
)
// Empty state so the UI doesn't look broken.
// Always tell the user what they can do next.
}
const daysSinceWatered = (timestamp) => {
const msPerDay = 1000 * 60 * 60 * 24
return Math.floor((Date.now() - timestamp) / msPerDay)
}
return (
<section className="plant-list">
<h2>Your Plants</h2>
<ul>
{plants.map((plant) => {
const days = daysSinceWatered(plant.lastWatered)
const thirsty = days >= plant.frequencyDays
// Compute thirsty status on the fly.
// Cheap calculation. Premature optimization would be storing this.
return (
<li
key={plant.id}
className={thirsty ? 'plant thirsty' : 'plant happy'}
>
<div className="plant-meta">
<strong className="plant-name">{plant.name}</strong>
<span className="plant-when">
{days === 0
? 'Watered today'
: `Last watered ${days} ${days === 1 ? 'day' : 'days'} ago`}
</span>
{thirsty && <span className="badge">THIRSTY</span>}
</div>
<div className="plant-actions">
<button
onClick={() => waterPlant(plant.id)}
className="btn-primary"
>
Water
</button>
<button
onClick={() => removePlant(plant.id)}
className="btn-danger"
>
Remove
</button>
</div>
</li>
)
})}
</ul>
</section>
)
}
export default PlantList
文件4 — 统计功能
// src/components/StatsPanel.jsx
import usePlantStore from '../store/usePlantStore'
function StatsPanel() {
const plants = usePlantStore((state) => state.plants)
// We subscribe to the plants array because our stats depend on it.
// When plants change, this re-renders with fresh totals.
const total = plants.length
const thirstyCount = plants.filter((plant) => {
const days = (Date.now() - plant.lastWatered) / (1000 * 60 * 60 * 24)
return days >= plant.frequencyDays
}).length
const happyCount = total - thirstyCount
return (
<aside className="stats-panel">
<h2>Quick Stats</h2>
<div className="stat-row">
<span>Total plants</span>
<strong>{total}</strong>
</div>
<div className="stat-row">
<span>Needs water</span>
<strong className="danger">{thirstyCount}</strong>
</div>
<div className="stat-row">
<span>Happy plants</span>
<strong className="success">{happyCount}</strong>
</div>
{thirstyCount > 0 && (
<p className="nudge">
{thirstyCount === 1
? 'One plant is waiting on you.'
: `${thirstyCount} plants are waiting on you.`}
</p>
)}
</aside>
)
}
export default StatsPanel
文件5 — 应用外壳
// src/App.jsx
import AddPlantForm from './components/AddPlantForm'
import StatsPanel from './components/StatsPanel'
import PlantList from './components/PlantList'
import './App.css'
function App() {
return (
<div className="app">
<header className="app-header">
<h1>Plant Hydration Station</h1>
<p className="tagline">Don't let them down</p>
</header>
<div className="grid">
<AddPlantForm />
<StatsPanel />
</div>
<PlantList />
</div>
)
// Notice something beautiful here.
// No Provider wrapping anything.
// No props passed to any component.
// Every component reaches into the store on its own.
}
export default App
文件6 — CSS样式
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #F5F3FF;
color: #2D3436;
}
.app { max-width: 960px; margin: 0 auto; padding: 24px; }
.app-header {
background: linear-gradient(135deg, #6C5CE7, #A29BFE);
color: white;
padding: 24px 28px;
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(108, 92, 231, 0.15);
}
.app-header h1 { margin: 0; font-size: 28px; }
.tagline { margin: 4px 0 0; opacity: 0.9; font-style: italic; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
.add-plant-form, .stats-panel, .plant-list {
background: white;
padding: 20px 22px;
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.field { display: block; margin-bottom: 14px; }
.field span { display: block; font-size: 13px; color: #636E72; margin-bottom: 6px; }
.field input {
width: 100%;
padding: 10px 12px;
border: 1px solid #DFE6E9;
border-radius: 8px;
font-size: 14px;
}
.freq-input { display: flex; align-items: center; gap: 10px; }
.freq-input input { width: 80px; }
button {
border: none;
padding: 10px 18px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
}
.add-plant-form button[type="submit"] {
background: #6C5CE7;
color: white;
width: 100%;
}
.btn-primary { background: #0984E3; color: white; }
.btn-danger { background: #FFE5E0; color: #E17055; }
.stat-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #F1F2F6; }
.stat-row:last-of-type { border: none; }
.stat-row .danger { color: #E17055; }
.stat-row .success { color: #00B894; }
.nudge { background: #FFF5F0; color: #E17055; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; }
.plant-list ul { list-style: none; padding: 0; margin: 0; }
.plant { display: flex; justify-content: space-between; align-items: center; padding: 14px 6px; border-bottom: 1px solid #F1F2F6; }
.plant:last-child { border: none; }
.plant.thirsty .plant-name::before { content: "🚨 "; }
.plant-meta { display: flex; flex-direction: column; gap: 3px; }
.plant-name { font-size: 15px; }
.plant-when { font-size: 12px; color: #636E72; }
.badge { background: #FFE5E0; color: #E17055; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: bold; display: inline-block; margin-top: 2px; }
.plant-actions { display: flex; gap: 8px; }
.empty-state { text-align: center; padding: 40px 20px; color: #636E72; }
运行 npm run dev,添加植物,刷新页面后确认它们仍在列表中,给其中一株浇水并观察时间戳是否重置。本地存储会将相同数据保留到第二个标签页中。
这就是你完成的应用程序,从现在开始你可以按照自己的喜好对其进行样式设计。
使用浏览器工具验证
进入“应用/存储”→“本地存储”,键 plant-hydration-v1 中保存着持久化的 JSON 数据。Persist middleware 会在数据变化时立即写入,并在页面渲染前重新加载数据。结合 devtools middleware 与 Redux DevTools 扩展,对于数据量较大的应用,每个操作都会以时间轴形式展示。
常见错误
- 全存储钩子问题 —— 使用没有选择器的
useStore()会导致每次数据变化时都重新渲染页面,务必使用选择器。 - 从选择器获取的新鲜对象问题 —— 可通过使用
useShallow或拆分钩子来解决。
plants中计算出thirstyCount;无需保留第二个过时的计数器。name——缺少此字段会在启动时引发错误。create函数即可实现共享;针对单实例需求还有原生API可用。getState、执行操作、断言结果来及早发现回归问题。何时不宜使用Zustand
useState——适用于真正的局部UI操作(模态框、悬停效果、字段草稿等)。
TypeScript 设计思路
import { create } from 'zustand'
type Plant = {
id: number
name: string
frequencyDays: number
lastWatered: number
}
type PlantState = {
plants: Plant[]
addPlant: (name: string, frequencyDays: number) => void
waterPlant: (id: number) => void
removePlant: (id: number) => void
thirstyCount: () => number
}
const usePlantStore = create<PlantState>((set, get) => ({
plants: [],
addPlant: (name, frequencyDays) =>
set((state) => ({
plants: [
...state.plants,
{ id: Date.now(), name, frequencyDays, lastWatered: Date.now() },
],
})),
waterPlant: (id) =>
set((state) => ({
plants: state.plants.map((p) =>
p.id === id ? { ...p, lastWatered: Date.now() } : p
),
})),
removePlant: (id) =>
set((state) => ({
plants: state.plants.filter((p) => p.id !== id),
})),
thirstyCount: () =>
get().plants.filter((p) => {
const days = (Date.now() - p.lastWatered) / (1000 * 60 * 60 * 24)
return days >= p.frequencyDays
}).length,
}))
只需一种存储结构类型加上 create<...> 函数;其余部分可通过推断自动处理。
更宏观的视角
Zustand 并没有取代已安装的 Redux —— 重写成本很高 —— 但新的 React 应用越来越倾向于使用更轻量的客户端状态管理方案。在近期几轮的 State of React 满意度调查中,Zustand 在“会再次使用”的选项中位居前列。常见的 2026 年技术栈配置为:用 Zustand 管理客户端状态,用 TanStack Query 管理服务器端状态,偶尔使用 Jotai 的原子组件,用 Context 管理主题和配置,只有在已有 Redux Toolkit 时才继续使用它,而本地 UI 则直接用 useState。
这种组合通常比以 Redux 为核心的框架更快上手,也能减少开发人员日常的困扰。
Zustand Bear
开发团队依然会制定状态存储的规范(命名、切片边界、持久化键),因为缺乏规范的自由只会带来混乱。这些规范的优点在于简洁:选择器范围有限,操作与状态紧邻存放,中间件是按需引入的,且服务器缓存不会进入客户端存储。只要保持这种清晰的边界,Zustand就能以十行代码解决Redux百行代码的初始化问题——而无需永远将每个应用都视为计数器演示案例。
除了组件示例之外,同样的设计模式也适用于购物车、功能标志、向导草稿以及界面装饰元素。从单个存储文件开始,当用户担心丢失工作成果时再添加持久化功能,遇到细微的错误时就引入开发者工具,当文件滚动条变得毫无用处时再加入切片功能,而对于任何需要与 API 交互的操作则继续使用 Query。这样的发展路径正是大多数成功的 Zustand 代码库的实际成长方式:规模小、结构清晰,且厌恶那些无法提升安全性的繁琐流程。
深入探讨选择器与性能
选择器的规范使用是决定仪表板响应迅速还是莫名迟缓的关键。每一次不必要的重新渲染都会导致 JSX 代码再次执行、依赖属性的效应函数重新运行,以及子组件的同步处理。只有当选择器返回稳定的原始数据或经过仔细比较的结构时,Zustand 的监听器模型才能保持高效。
建议选择布尔值、数字和字符串类型。当选择了动作函数后,由于它存储在 store 对象中,因此在多次更新中通常保持稳定,所以可以在两个钩子函数中同时使用 count 和 increment。如果组件只需要 items.length,则应避免选择整个数组,而应直接选择长度值(或由此衍生的布尔值),这样其他地方的更新就不会唤醒处于闲置状态的组件。
相等性很重要。默认的比较方式是 Object.is。这就是为什么从选择器中返回 { a, b } 会失败的原因:即使 a 和 b 没有变化,新的对象也会使 Object.is 的比较失败。useShallow 只会比较一层字段。对于深层结构,要么将状态规范化以便 UI 能读取扁平化的字段,要么刻意计算出原始类型的特征值。
列表需要特别关注。如果每个组件仅在成员关系或项目标识发生变化时才需要数组引用,那么将plants拆分为三个组件是可行的。如果某个面板仅显示名称,建议使用能返回已排序ID字符串的选择器;这样即使与植物相关的其他字段发生变化,该列表依然保持稳定。
持久化陷阱与版本控制
在架构未发生变化之前,持久化中间件看起来非常神奇。务必为存储键设置一个稳定的name。当持久化状态的格式发生变化时,应提升版本号,并提供migrate函数,以避免旧版JSON在加载时导致应用崩溃。部分持久化(partialize)功能可将敏感信息及临时性的UI标志排除在本地存储之外——令牌和一次性弹窗的“开启”状态通常不应保存在磁盘上。
要注意数据同步的时机:在完成数据重新加载之前,首次渲染时可能会短暂显示默认值。对于服务端渲染或基于服务器绘制内容的框架,应将依赖持久化值的界面功能置于数据同步完成标志之后再显示,或接受短暂的默认主题显示。需将所选方案记录下来,以免团队成员误将实际是数据同步延迟导致的异常问题当作需要修复的故障。
跨标签页的行为也会让人感到意外。通过storage事件,一个标签页对本地存储的写入操作会在其他标签页中显示出来,但Zustand的默认持久化路径并不会自动合并同时进行的编辑操作。对于需要协同工作的标签页,要么采用最后写入者胜的规则,要么在存储层之上添加显式的BroadcastChannel同步机制。
设计始终保持简洁的操作
良好的状态操作应当规模较小、以用户意图命名且不包含 JSX。addPlant、waterPlant 和 removePlant 比 setPlants 更能避免在界面中显示冗余数据。应将验证逻辑置于操作附近:拒绝空名称、限制浇水间隔时间、忽略未知的 ID。在操作中提前返回比让错误数据在存储中停留等待渲染周期要更清晰。
当界面需要反映处理进度时,异步操作应明确设置 loading 和 error 字段;而那种“发完就忘”的日志记录方式则可以省略这些标志。当多个异步请求同时执行时,应捕获请求 ID 或使用取消控制器,以防止旧响应覆盖新响应。所有这些都不需要中间件——只需注意操作的设置顺序即可。
派生辅助函数可以作为普通函数存在于 store 外部(最便于测试),也可以作为在选择器中计算的 getter。建议使用同时被 store 和 UI 导入的纯函数,这样 Jest 即可在不依赖 React 的情况下验证浇水逻辑。
植物应用操作指南
粘贴这六个文件时,确保导入路径与 Vite 模板一致(从 components 导入时使用 ../store/...)。如果刷新后列表显示为空,请检查 DevTools 中的持久化键,确认 name: 'plant-hydration-v1' 与预期相符。浇水操作应更新 lastWateredAt(或类似字段),这样无需重新加载整个页面,“缺水”状态选择器就能随之改变。
App.css中的样式设计刻意保持简洁。可以自由更换字体和颜色;学习重点在于数据流的处理,而非视觉效果的提升。添加第四个组件——比如一个“给所有缺水植物浇水”的按钮——是个不错的练习:先选中那些缺水的元素ID,然后通过一个set操作一次性为这些ID对应的植物浇水。这样的练习有助于加深对批量更新的理解,而非从组件内部反复调用set函数。
值得记录的团队规范
需就文件结构(stores/与集中式功能文件夹)、命名规则(useXStore)以及操作是可直接调用API还是必须通过Query mutation来更新Zustand达成一致。许多团队完全禁止将服务器列表放入Zustand中,只在其中保留临时的客户端标志。应在README中明确写明这一规则,这样就能避免一半的代码库重复实现缓存功能。
还需确定DevTools的命名方式:devtools中间件会接收一个存储名称,这样在打开五个存储时扩展面板仍能保持可读性。持久化键应以应用为单位进行命名(如myapp-theme-v1),以避免在共享域名下出现冲突。
比较的是实际影响,而不仅仅是代码行数
Redux Toolkit 大幅简化了传统 Redux 的复杂度,因此“100 行代码对比 10 行”在某种程度上只是夸张说法。更本质的差异在于概念层面的结构:slice 模式与单次 create 调用、reducer 与直接执行 action、中间件管道与可选的包装层、Provider 树结构与模块单例模式。那些已经习惯使用 reducer 的工程师在两种框架中都能高效工作;而那些希望共享客户端状态却又不愿受状态机理念束缚的工程师,通常在 Zustand 中能更快完成功能开发。
但这些理由都不能成为跳过代码审查的借口。即便只有十行代码,如果未经授权就存储个人敏感信息,仍可能存在安全漏洞;如果每个组件都去获取全部数据,也可能出现性能问题。应将状态管理库视为公共模块 API:使用稳定的动作名称、明确记录数据持久化键,并为各种异常情况(如空列表、无效 ID、获取失败等)编写测试用例。
形成学习闭环
在植物应用正常运行后,故意破坏它:移除选择器、返回对象字面量、无名称地持久化存储、保存派生计数。先观察一次故障模式,以便在代码审查时能够识别它们。然后再恢复规范的编程模式。这样的简短实验对长期记忆的帮助,远超过另一个精心设计的计数器演示。
Zustand 的观点是,大多数客户端状态都很乏味——标志位、草稿、购物车、浏览器缓存等——而乏味的状态就应该对应乏味的 API。将服务器端的真实数据保存在专门设计的缓存库中,将本地 UI 用 useState 管理,而把那些导致 prop 传递变得极其繁琐的跨领域客户端数据留作特殊处理。只要始终如一地这样做,“只需十行代码”的说法就不再像是营销话术,而会真正成为代码库的结构特征。