iT邦幫忙

2023 iThome 鐵人賽

DAY 13
2
SideProject30

營養師不開菜單要用 Next.js 13 寫全端系列 第 13

營養師不開菜單的第十三天 - 不需要 React Provider 管理狀態的 Zustand

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20230927/20152073fzmwXa5x7f.jpg

吃水果好麻煩,買來之後洗完還要削皮去籽再切一切,但我還是想要天然的維生素礦物質啊!那為什麼我不買果汁喝就好?在使用 Redux 跟 Context API 的時候有沒有這種想法,光是設定 store 和 action 就好麻煩,調用 selector dispatch 還要引入一堆 hook,我只是要存一個 state ! 卻像吃切盤蘋果一樣複雜的前置處理,這時候使用如果汁般簡單快速的 Zustand,真的減輕很多程序啊!

什麼是 Zustand?

Zustand 是一個以 TypeScript 撰寫,並藉由 react hook 的概念來進行全域的狀態管理,在 react 應用程式中主要是以發布訂閱模式以及 react useSyncExternalStore 的原理來進行狀態管理,其中以 new Set() 的方式來儲存訂閱的內容,而如果要在 react 外的 JavaScript 專案中使用,官方也提供了 vanilla.ts 的子 Package ,可以調用 api 來使用。

為什麼要用 Zustand?

  • 非常輕量:對比社群上高人氣的狀態管理套件 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

使用 create 建立一個 zustand store hook,並且回傳 set 及 get function 可對 state 做操作

若要設定 initial state 的型別,可在 create 的後面設定泛型 create<CoutesStore>

state

上方範例的 bears,在 zustand 的世界中可以將多個 state 包成一個 object,也可以直接設置多個 state

  1. 包成一個 object:

    countes: {
      bears: 0,
      fishes: 0,
      fruits: 0
    },
    increasePopulation: () =>
      set((state) => ({
        ...state,
        countes: { ...state.countes, bears: state.countes.bears + 1 }
      })),
    removeAllBears: () => set({ bears: 0 })
    
  2. 直接設置多個 state

    bears: 0,
    fishes: 0,
    fruits: 0,
    increasePopulation: () => 
        set((state) => ({ bears: state.bears + 1 })),
    removeAllBears: () => set({ bears: 0 })
    

get

在設定 action 時可以取得當前狀態的功能。

const currentBears = get().bears

set

用於更新狀態。可以接受一個新的 state 物件,或是一個 callback function,callback function 接收當前狀態 (也就是舊的狀態) 作為其唯一參數,並回傳一個新的狀態物件。

使用方法:

假設 state 是

bears:0,
fishes:0,
fruits:0
  1. 使用舊的狀態進行更新

    • 以 callback 的 state 取得當前狀態

      set(state => ({ bears: state.bears + 1 }))
      
    • 以 get 取得當前狀態

      const currentBear = get().count
      set({ count: currentCount * 2 })
      
  2. 直接回傳想要更新的狀態物件

    set({ bears: 1 })
    
  3. 使用 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

  1. 引入 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>
    		)
    }
    
  2. 傳遞 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>
    		)
    }
    
  3. 傳遞僅需要的 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>
    		)
    }
    
  4. 使用 shallow

    shallow 可以在同時選擇多個需要的 state 或 action,並進行物件 reference 的淺比較。它利用 === 原理比較前一個和當前值的最上層屬性,來防止不必要的 re-render。詳細的範例及原理下方會說明~

Shallow

功能:

shallow 是一種等值檢查 ( === ) function,用於比較前後兩次的 state 選擇結果。主要目的是在 selector 與 state 之間有 reference 不同但值相同的情況下,防止不必要的 re-render。

使用情境:

  1. 選擇部分狀態:當你想要選擇狀態的某一部分而不是整體狀態時,例如:

    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 比較會偵測到無變化來防止組件重新渲染。

  2. 使用工具更新狀態:例如,當使用 immer* 進行狀態更新時,你可能在內部更改了 object 或 array 的內容,但保持了外部引用不變。在這種情況下,shallow 是非常有用的。

  3. 自訂義 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
)

Persist

接下是我最喜歡的功能,尤其是在 Nextjs 的 SSR 環境開發時,經常會遇到想要把一些簡單資料存在 瀏覽器 local 端時非常頭痛的情況。當使用 redux 或是 context API 時想要初始化以及存取 local storage 的資料,可能會需要使用 useEffect 使在客戶端的情況下將瀏覽器的資料存進 initialState,或是每次存取都要再調用一次同步。

而 Zustand 提供了一個永久化儲存的 Middleware - persist

  1. 初始化 (initial): 當 store 第一次被初始化時,persist 會檢查 localStorage(或你指定的存儲方式)中是否有之前保存的狀態。如果有,它會使用這些資料初始化 store,而不是在 create 中設置的初始值。
  2. 更新: 每當透過 action 更新 store,persist 會自動將新的狀態保存到 localStorage(或你指定的存儲方式)。這確保了即使在頁面刷新後,狀態仍然被保存和恢復。

使用方法

  1. 在 create 的 callback 外包一層 persist ,並在 persist 的第二參數設定 config,預設是 local storage
  2. config 中必填欄位為 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 }stateversion 是固定的!

{
    "state": {
      "countes": {
        "bears": 0,
        "fishes": 0,
				"fruits": 0,
      }
    },
    "version": 1
}

Subscribe

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 回傳資料同步可以有以下幾種作法:

  1. 或是在 create 中建立一個 getInitial 的 action 並在 client side 使用 useEffect 來 initial
  2. 因為 Zustand 沒有 provider 的,所以可以建立一個 StoreInitailizer 來做處理,並放在 Server component 調用
  3. 如果有其他方法可以留言指教!非常感激!!

參考資料

浅析 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

https://ithelp.ithome.com.tw/upload/images/20230928/20152073knpgJokWpX.png


上一篇
營養師不開菜單的第十二天 - OAuth 權限申請:Facebook、Twitter、Google 及 Github
下一篇
營養師不開菜單的第十四天 - 為什麼要用 React-Beautiful-Dnd 做拖曳效果
系列文
營養師不開菜單要用 Next.js 13 寫全端30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言