Inside zustand
zustand 參考版本為 v5.0.0-beta.0
從下方 counter 範例開始看起,主要分兩個部分:
- creating a store (L8-L11)
- binding components (L14-L15)
import { create } from 'zustand'
type CountStore = {
count: number
inc: () => void
}
const useCountStore = create<CountStore>()((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
function Counter() {
const count = useCountStore(({ count }) => count)
const inc = useCountStore(({ inc }) => inc)
return (
<div>
<span>{count}</span>
<button onClick={inc}>one up</button>
</div>
)
}
Creating a store
第一步是透過 create
,得到 useCountStore
。create
對參數的判斷,可以對應到官方文件中的 TypeScript Guide,主要是為了處理型別推導的問題
The difference when using TypeScript is that instead of writing
create(...)
, you have to writecreate<T>()(...)
(notice the extra parentheses()
too along with the type parameter) whereT
is the type of the state to annotate it.
補充:從 discussions#1865 來看,主要與 zustand middlewares 有關
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create
從上方程式碼得知,create
會調用 createImpl
,這裡繼續深入 createImpl
的細節,先透過 createStore
建立一個 vanilla store
import { createStore } from './vanilla.ts'
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
// ...
}
vanilla store 為常見的 observer pattern
- 透過
Set
存放 listener callback (L5) setState
做了 shallow merging 處理(L16-19),可以對應到官方文件提到:
The
set
function merges state at only one level. If you have a nested object, you need to merge them explicitly.
getInitialState
主要是為了支援 server-side rendering (L26-27, L36),相關討論在 pull#2277
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
let state: TState
const listeners: Set<Listener> = new Set()
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
replace ?? (typeof nextState !== 'object' || nextState === null)
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState: StoreApi<TState>['getState'] = () => state
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
稍微理解 vanilla store 後,回到 createImpl
在第四行 useBoundStore
是接收 selector
參數的 higher order function
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
// ...
}
為了理解 useBoundStore
繼續看到 useStore
useStore
主要是透過 react 的 useSyncExternalStore
來整合 vanilla store:
export function useStore<TState, StateSlice>(
api: StoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
const slice = useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState()),
)
useDebugValue(slice)
return slice
}
再回到 createImpl
的程式碼,整理一下目前已知:
api
是 vanilla store 的介面useBoundStore
是基於useSyncExternalStore
的 react hook,透過selector
選取 vanilla store stateapi
會被複製到useBoundStore
上(透過Object.assign
)
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
補充:Object.assign(useBoundStore, api)
,可以對應到官方文件中提到:
Reading/writig state and reacting to changes outside of components
const useDogStore = create(() => ({ paw: true, snout: true, fur: true }))
// Getting non-reactive fresh state
const paw = useDogStore.getState().paw
// Listening to all changes, fires synchronously on every change
const unsub = useDogStore.subscribe(console.log)
useDogStore.setState({ paw: false })
// Unsubscribe listeners
unsub()
Binding Components
在上述結果得知 useBoundStore
是基於 useSyncExternalStore
實現,當調用 useBoundStore(selector)
:
- 會新增一個觸發 component re-render 的 listener 至 vanilla store
- 透過
selector
得到的 snapshot,需要符合useSyncExternalStore - getSanpshot
的規則
While the store has not changed, repeated calls to getSnapshot must return the same value. If the store changes and the returned value is different (as compared by
Object.is
), React re-renders the component.
補充:在目前 react 的版本(v18.3.1),任何同步外部儲存的狀態,都需要透過 useSyncExternalStore
來避免 tearing。除了 zustand 外,@tanstack/react-query 也可以看到 useSyncExternalStore
的身影
Summary
以下簡單總結:
- 調用
create
得到useBoundStore
的流程:- 建立一個儲存 state 的 external store,在 zustand 稱為 vanilla store
- 建立一個 react hook,基於
useSyncExternalStore
整合 vanilla store,在 zustand 稱為useBoundStore
- 將 vanilla store API 複製至
useBoundStore
(目前實作是透過Object.assign
) - 回傳
useBoundStore
useBoundStore(selector)
是基於useSyncExternalStore
,使用Object.is
比對 output 是否變更,觸發 re-render