吃水果好麻煩,買來之後洗完還要削皮去籽再切一切,但我還是想要天然的維生素礦物質啊!那為什麼我不買果汁喝就好?在使用 Redux 跟 Context API 的時候有沒有這種想法,光是設定 store 和 action 就好麻煩,調用 selector dispatch 還要引入一堆 hook,我只是要存一個 state ! 卻像吃切盤蘋果一樣複雜的前置處理,這時候使用如果汁般簡單快速的 Zustand,真的減輕很多程序啊!
Zustand 是一個以 TypeScript 撰寫,並藉由 react hook 的概念來進行全域的狀態管理,在 react 應用程式中主要是以發布訂閱模式以及 react useSyncExternalStore
的原理來進行狀態管理,其中以 new Set() 的方式來儲存訂閱的內容,而如果要在 react 外的 JavaScript 專案中使用,官方也提供了 vanilla.ts
的子 Package ,可以調用 api 來使用。
非常輕量:對比社群上高人氣的狀態管理套件 redux、recoil、mobx,動輒 2 ~12 MB 不等,Zustand 僅有不到 400 KB 的大小
不需要 Provider 包覆即可使用:以 NextJs 為例,v13 以下由於並沒有 server component 和 client component 的切分,所以當使用 Redux 時為了解決 SSR 初始化及 hydration 的問題,需要搭配 next-redux-wrapper
使用;而雖然 v13 以上可以透過在 server component 引入含有 provider 的 client component,但任何行為都還是需要在 provider 的 children 中才能使用
使用及設置方法簡潔:例如下方的使用方法,不需要設定 reducer action 或是 context,直接建立一個 custom hook ,即可在應用程式中調用
快速且提高效能:以 react context 為例,在組件中取得 state 時,是將 context 中定義的 state 全部回傳,其中若有未使用到的 state 更新時,其他有調用 context 的 component 都會 re-render,而 Zustand 可以藉由設置 selector 僅回傳該 component 需要的 state,範例如下:
const bears = useCountStore(store => store.bears)
如此一來 store 中其他 state 更新也不會影響到使用 bears 的 component 了!
// useCountStore.ts
import create from 'zustand'
const useCountStore = create((set, get) => ({
bears: 0,
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 })
}))
使用 create 建立一個 zustand store hook,並且回傳 set 及 get function 可對 state 做操作
若要設定 initial state 的型別,可在 create 的後面設定泛型
create<CoutesStore>
上方範例的 bears,在 zustand 的世界中可以將多個 state 包成一個 object,也可以直接設置多個 state
包成一個 object:
countes: {
bears: 0,
fishes: 0,
fruits: 0
},
increasePopulation: () =>
set((state) => ({
...state,
countes: { ...state.countes, bears: state.countes.bears + 1 }
})),
removeAllBears: () => set({ bears: 0 })
直接設置多個 state
bears: 0,
fishes: 0,
fruits: 0,
increasePopulation: () =>
set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 })
在設定 action 時可以取得當前狀態的功能。
const currentBears = get().bears
用於更新狀態。可以接受一個新的 state 物件,或是一個 callback function,callback function 接收當前狀態 (也就是舊的狀態) 作為其唯一參數,並回傳一個新的狀態物件。
使用方法:
假設 state 是
bears:0,
fishes:0,
fruits:0
使用舊的狀態進行更新
以 callback 的 state 取得當前狀態
set(state => ({ bears: state.bears + 1 }))
以 get 取得當前狀態
const currentBear = get().count
set({ count: currentCount * 2 })
直接回傳想要更新的狀態物件
set({ bears: 1 })
使用 immer
直接修改
與 redux toolkit 及 useState 相同,當要使用當前 state 進行狀態更新時,理論上盡可能的使用 immutably 的方式來修改,
const useCountStore = create((set) => ({
countes: {
bears:0,
fishes:0,
fruits:0
},
increasePopulation: () =>
set((state) => ({
countes: {
...state.countes,
bears: state.countes.bears + 1
},
})),
}))
但如果我今天的資料巢狀又巢狀,我想要改最深層的資料,這樣要 Spread 到天荒地老,所以這時候救世主 immer
,只要在 set 的 callback function 以 produce
包覆,就可以讓你在不修改原始狀態的情況下,安全又輕鬆的更新狀態!
import { produce } from 'immer';
const useCountStore = create((set) => ({
countes: {
bears:0,
fishes:0,
fruits:0
},
increasePopulation: () => set(produce((draft) => {
draft.countes.bears += 1;
}))
}));
在元件中可以依據你的 hook 中傳遞的 selector 有取得你想要的 state 或是 action
引入 hook 不傳 selector,直接用 hook 取值
如果
useCountStore
的任何部分更改(不只是countes
),這個 component 都可能重新渲染。
import useCountStore from '@/hooks/useCountStore'
const Counter = () => {
const { countes, increasePopulation } = useCountStore()
return (
<div>
<div>bear counts: { countes.bears }</div>
<button onClick={increasePopulation}>
Add one bear
</button>
</div>
)
}
傳遞 selector 並解構取值
如果只有 countes
更改,則 component 可能會重新渲染。但如果 store 中的其他部分更改,而 countes
保持不變,則此 component 不會重新渲染。
當
countes
物件中的任何值(例如bears
,fishes
,fruits
等屬性)發生變化時,組件component 都會重新渲染。
import useCountStore from '@/hooks/useCountStore'
const Counter = () => {
const { bears, fishes } = useCountStore(store => store.countes)
return (
<div>
<div>bear counts: {bears}</div>
<div>fish counts: {fishes}</div>
</div>
)
}
傳遞僅需要的 selector
如果只有 bears
更改,只有與 bears
相關的 component 部分會重新渲染,而 fishes
則不會,相反也是一樣。
import useCountStore from '@/hooks/useCountStore'
const Counter = () => {
const bears = useCountStore(store => store.countes.bears)
const fishes = useCountStore(store => store.countes.fishes)
return (
<div>
<div>bear counts: {bears}</div>
<div>fish counts: {fishes}</div>
</div>
)
}
使用 shallow
shallow
可以在同時選擇多個需要的 state 或 action,並進行物件 reference 的淺比較。它利用 ===
原理比較前一個和當前值的最上層屬性,來防止不必要的 re-render。詳細的範例及原理下方會說明~
shallow
是一種等值檢查 ( ===
) function,用於比較前後兩次的 state 選擇結果。主要目的是在 selector 與 state 之間有 reference 不同但值相同的情況下,防止不必要的 re-render。
選擇部分狀態:當你想要選擇狀態的某一部分而不是整體狀態時,例如:
import useCountStore from '@/hooks/useCountStore';
import shallow from 'zustand/shallow'; // 要記得引入
const Counter = () => {
const { bears, fishes } = useCountStore(store => ({
bears: store.countes.bears,
fishes: store.countes.fishes
}), shallow);
// or
// const [bears, fishes] = useCountStore((store) =>
// [store.countes.bears, store.countes.fishes], shallow)
return (
<div>
<div>bear counts: {bears}</div>
<div>fish counts: {fishes}</div>
</div>
);
}
只有當你從 useCountStore
中選取的部分(如 bears 和 fishes)改變時,會引起 countes
物件的 top-level reference 發生變化,組件才會重新渲染。如果 countes
物件的其他子屬性發生變化(例如 fruits),使用 shallow
比較會偵測到無變化來防止組件重新渲染。
使用工具更新狀態:例如,當使用 immer
* 進行狀態更新時,你可能在內部更改了 object 或 array 的內容,但保持了外部引用不變。在這種情況下,shallow
是非常有用的。
自訂義 shallow: 如果想自訂義重新渲染的情況,可以在 hook 的第二個參數建立自己的比較 function
const { bears, fishes } = useCountStore(store => ({
bears: store.countes.bears,
fishes: store.countes.fishes
}),
(oldCountes, newCountes) => compare(oldCountes, newCountes)
);
shallow
進行的是浅層比較(shallow comparison)。當你選擇狀態的某個部分並將其返回時,shallow
會檢查這部分狀態的每個頂層屬性(對於對象)或頂層元素(對於數組),看看它們是否與上次選擇的值相同。這意味著它不會深入到對象的嵌套層次或檢查數組的內部結構,只會檢查頂層的引用是否相同。
實作時使用 shallow 發現會一直跳出棄用的警告,翻了 github 的 issue 有發現之後的版本中如果有需要使用 shallow 功能,可以直接在定義 hook 時將 create 改為 createWithEqualityFn
使用,function 的第一個參數是原本的 callback function,而第二個參數可以設置 shallow 屬性:
import { shallow } from 'zustand/shallow'
import { createWithEqualityFn } from 'zustand/traditional'
const useSetup = createWithEqualityFn<SettingStore>(
(set) => ({
<----- 自訂義內容 ------>
}),
shallow
)
接下是我最喜歡的功能,尤其是在 Nextjs 的 SSR 環境開發時,經常會遇到想要把一些簡單資料存在 瀏覽器 local 端時非常頭痛的情況。當使用 redux 或是 context API 時想要初始化以及存取 local storage 的資料,可能會需要使用 useEffect 使在客戶端的情況下將瀏覽器的資料存進 initialState,或是每次存取都要再調用一次同步。
而 Zustand 提供了一個永久化儲存的 Middleware -
persist
initial
): 當 store 第一次被初始化時,persist
會檢查 localStorage
(或你指定的存儲方式)中是否有之前保存的狀態。如果有,它會使用這些資料初始化 store,而不是在 create
中設置的初始值。persist
會自動將新的狀態保存到 localStorage
(或你指定的存儲方式)。這確保了即使在頁面刷新後,狀態仍然被保存和恢復。name
: storage 的 key 值,第二個參數 storage
是選填,如果要設定為 session storage 再更改⚠️ 原本
getStorage
已被棄用,需使用storage
default 是createJSONStorage(() => localStorage)
import { produce } from 'immer';
import create from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useCountStore = create(
persist(
(set) => ({
countes: {
bears: 0,
fishes: 0,
fruits: 0,
},
increasePopulation: () => set(produce((draft) => {
draft.countes.bears += 1;
}))
}),
{
name: 'countes', // 唯一的名稱,用於 localStorage 的 key
storage: createJSONStorage(() => sessionStorage) // 選填,設置為 sessionStorage 的方法
}
)
);
以 object 格式儲存,{ state, version: options.version }
,state
及 version
是固定的!
{
"state": {
"countes": {
"bears": 0,
"fishes": 0,
"fruits": 0,
}
},
"version": 1
}
subscribe
方法是可以讓我們訂閱 store 的變化。每當 store 的 state 發生變化時,subscribe
提供的 callback function 就會被觸發。這讓我們可以在 state 發生變化時進行一些特定的操作,而不必直接在 React 組件中做。
subscribe(selector, callback, options?: { equalityFn, fireImmediately }): Unsubscribe
import useCountStore from '@/hooks/useCountStore';
const unsubscribe = useCountStore.subscribe(
// selector
state => state.countes.bears,
// callback
bears => {
console.log(`New bear count: ${bears}`);
}
);
// 如果你想要在某個時刻取消訂閱,可以調用:
unsubscribe();
// 如果想要一次取消訂閱所有 useCountStore 的 listener
useCountStore.destroy()
想要分享一個 Side Project 在實作時遇到的問題,如果我的 initial state 希望是由 api 回傳資料同步可以有以下幾種作法:
StoreInitailizer
來做處理,並放在 Server component 調用浅析 zustand 状态管理器
https://www.leezhian.com/web/framework/zustand-analysis#%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90
How to use Zustand
https://refine.dev/blog/zustand-react-state/#build-a-to-do-app-using-zustand
RFC: deprecate equalityFn towards v5 (migration path exists)
https://github.com/pmndrs/zustand/discussions/1937
Recipes
https://docs.pmnd.rs/zustand/recipes/recipes
Did NextJS 13 Break State Management?
https://youtu.be/OpMAH2hzKi8?t=668