Zustand 101

October 30, 2024

  • react
有任何問題,我能幫上忙的話都能到 github 發 issue 問我。

Preface

React 相較於 Vue,在集合管理狀態這邊,他有更多的選項,Vue3 目前官方僅推薦 pinia,vuex 則是可以使用在 Vue2 上,React 則多了 react-redux、recoil、zustand、mobx…等,每個都 有每個的特色,可以依據自己的需求去選擇。

這篇文章會著重在 zustand 上,如果是中小型專案,沒有需要太複雜的狀態管理,zustand 目前我認為是最容易運維的。

Create a store

下方是一個很簡單的登入範例,zustand 有一個亮點就是在處理 promise 這塊,他是可以直接兼容的,不用像 react-redux 還要使用 thunk API,題外話,基本上體感寫起來很像 Vue3 的 pinia。

import { create } from "zustand"
import { type LoginData } from "@/api/user-type"

export interface UserState {
  token: string
  name: string
  nickname: string
  login: (by: LoginData) => Promise<void>
  getInfo: () => Promise<void>
  logout: () => void
}

export const useUserStore = create<UserState>(set => ({
  token: "",
  name: "",
  nickname: "",
  login: async (data: LoginData) => {
    //...
  },
  getInfo: async () => {
    //...
  },
  logout: () => {
    set({
      token: "",
      name: "",
      nickname: "",
    })
  },
}))
import { type FC } from 'react'
import { useUserStore } from '@/store'

const App: FC = () => {
  const { token, name, nickname, logout, login, getInfo } = useUserStore()

  // your login process...

  return (
    <div>...</div>
  )
}

export default App

假設你的 store 也有儲存在 cookie,或著 localstorage,這邊可以這樣在做修改。

import { getToken, setToken, removeToken } from "@/lib/auth"

export const useUserStore = create<UserState>(set => ({
  token: getToken() || "",
  name: "",
  nickname: "",
  login: async (data: LoginData) => {
    //...

    setToken(token, expiration)
  },
  getInfo: async () => {
    //...
  },
  logout: () => {
    removeToken()

    set({
      token: "",
      name: "",
      nickname: "",
    })
  },
}))

Using with devtool

zustand 是可以直接使用 react-redux 的瀏覽器套件,但需要額外再 create 內在包一層。

import { devtools } from "zustand/middleware"
// import { toast } from "sonner"

export const useUserStore = create<UserState>()(
  devtools(
    set => ({
      token: getToken() || "",
      name: "",
      nickname: "",
      login: async (data: LoginData) => {
        try {
          const { token, expiration } = await loginUser(data) // login user

          setToken(token, expiration)
          set({ token }, false, "login/success")
        } catch (error) {
          console.error(error)

          // 示意:也可以使用 toast 去告知使用者
          // toast.error("登入失敗", {
          //   description: "請檢查帳號密碼是否正確",
          //   closeButton: true,
          // })
        }
      },
      getInfo: async () => {
        try {
          const { data } = await getUserInfo() // fetch user info

          const { name, nickname } = data

          set(
            {
              name,
              nickname,
            },
            false,
            "getInfo/success"
          )
        } catch (error) {
          console.error("getInfo error: ", error)

          // 示意:也可以使用 toast 去告知使用者
          // toast.error("驗證失敗", {
          //   description: "請重新登入",
          //   closeButton: true,
          // })
        }
      },
      logout: () => {
        removeToken()

        set(
          {
            token: "",
            name: "",
            nickname: "",
          },
          false,
          "logout/success"
        )
      },
    }),
    { name: "UserStore", enabled: import.meta.env.MODE === "development" }
  )
)

Immutable middleware

如果你的狀態是槽狀的 Array,很多組,例如:

const store = [
  {
    product: "Game",
    size: {
      xl: {
        price: 1000,
        sale: 50,
        detail: [
          {
            //...
          },
        ],
      },
      lg: {
        price: 500,
        sale: 70,
        detail: [
          {
            //...
          },
        ],
      },
      ml: {
        price: 200,
        sale: 80,
        detail: [
          {
            //...
          },
        ],
      },
      sm: {
        price: 100,
        sale: 80,
        detail: [
          {
            //...
          },
        ],
      },
    },
  },
  //...
]

且你恰好知道 immutable.js 這技術,這時你就可以做使用,由於 structure sharing (結構共享) 的特性,可以增加效能,做到部分更新。

// zustand 可使用 immer 來達到 immutable 的效果,需安裝 immer 套件
import { immer } from "zustand/middleware/immer"

export const useUserStore = create<UserState>()(
  immer(set => ({
    data: [...],
    increaseXLSale: (sale: number) => {
      set((state) => {
        state[0].size.xl.sale += sale
      })
    },
    decreaseXLSale: (sale: number) => {
      set((state) => {
        state[0].size.xl.sale -= sale
      })
    }
  }))
)

在 javascript 內,Object 及 Array,儲存的都是記憶體的位置 (reference),不是真正的值,所以當你要對 Array 進行修改時,你在 react 基本上都會使用 spread operator 或著 map 去 淺拷貝這個 Array,如果資料結過比較深跟複雜,你則需要使用深拷貝,一旦要更新,就必須整個複製一份,這是相當消耗效能的,所以 Facebook 的工程師就提出了,immutable.js 這概念,他會 一層一層往上找,只對父層的支點做拷貝,流程可以參考如下,我在網路上找到的結構共享的示意圖:

immutable

參考:https://zhenhua-lee.github.io/react/Immutable.html

有興趣的讀者可以實際去上手玩玩看

const map1 = Immutable.Map({ a: 1, b: 2, c: 3 })
const map2 = map1.set("b", 50) // 由於 immutable 的特性,所以會返回一個全新的物件,並不會影響到先前的物件
map1.get("b") // 2
map2.get("b") // 50

這篇文章也有介紹 immutable.js vs immer

Advance Usage

當然還有些進階用法,假如你的 zustand,需要同時跟 useContext 內的資料進行共享,這時 zustand 也可以搭配 useContext 來做使用,這部分範例 參考

import { createContext, useContext } from 'react'
import { createStore, useStore } from 'zustand'

const store = createStore(...)

const StoreContext = createContext()

const App = () => (
  <StoreContext.Provider value={store}>
    ...
  </StoreContext.Provider>
)

const Component = () => {
  const store = useContext(StoreContext)
  const slice = useStore(store, selector)

  ruturn {
    <div>...</div>
  }
}

亦或著,你想在 react 外部做使用,

import { useUserStore } from "@/store/user"

const someFn = () => {
  useUserStore.getState().doSomething()

  //...
}

搭配 useRef 及 useEffect,做訂閱型的狀態更新,這部分範例是 參考

const useScratchStore = create(set => ({ scratches: 0, ... }))

const Component = () => {
  // 獲得初始狀態
  const scratchRef = useRef(useScratchStore.getState().scratches)

  // 掛載時連到 store,卸載時斷開,並透過 useEffect 獲取變化
  useEffect(() => useScratchStore.subscribe(
    state => (scratchRef.current = state.scratches)
  ), [])

  return (
    <div>...</div>
  )

Conclusion

上面是我個人對 zustand 的理解,寫成筆記記錄一下,方便自己回顧,有些新的功能如果我有做到使用,我也會在補齊上去,javascript 的水真的很深啊。


Mayvis Chen
你好,我是 Mayvis Chen
住在台北,會點前端,會點後端,熱愛分享新知及愛喝奶茶的全端打雜工程師。