Intensive reading of zustand source code

zustand It is a very fashionable state management library and the fastest growing React state management library of Star in 2021. Its concept is very functional, and the API design is very elegant, which is worth learning.

summary

First introduce zustand How to use.

Create store

Create a store through the create function. The callback can get the get set, which is similar to the getState and setState of Redux. You can obtain the instantaneous value of the store and modify the store. Return a hook to access the store in the React component.

import create from 'zustand'

const useStore = create((set, get) => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 })
}))

The above example is a globally unique store. You can also create a multi instance store through createContext in combination with the Provider:

import create from 'zustand'
import createContext from 'zustand/context'

const { Provider, useStore } = createContext()

const createStore = () => create(...)

const App = () => (
  <Provider createStore={createStore}>
    ...
  </Provider>
)

Access store

Access the store in the component through useStore. Different from redux, both ordinary data and functions can be stored in the store, and the functions can also be obtained through selector syntax. Because the function reference is immutable, the following second example will not actually cause re rendering:

function BearCounter() {
  const bears = useStore(state => state.bears)
  return <h1>{bears} around here ...</h1>
}

function Controls() {
  const increasePopulation = useStore(state => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

If it is troublesome to call useStore multiple times to access variables, you can customize the compare function to return an object:

const { nuts, honey } = useStore(state => ({ nuts: state.nuts, honey: state.honey }), shallow)

Fine grained memo

useCallback can even skip ordinary compare and only care about the change of external id value, such as:

const fruit = useStore(useCallback(state => state.fruits[id], [id]))

The principle is that when the id changes, the return value of useCallback will change. If the return value of useCallback remains unchanged, the reference comparison of the compare function of useStore will be true, which is very clever.

set merge and overwrite

The second parameter of the set function is false by default, that is, the values are merged instead of overwriting the whole store, so you can use this feature to clear the store:

const useStore = create(set => ({
  salmon: 1,
  tuna: 2,
  deleteEverything: () => set({ }, true), // clears the entire store, actions included
}))

asynchronous

All functions support asynchrony, because modifying store does not depend on the return value, but calls set, so asynchrony is the same for the data flow framework.

Listen for specified variables

subscribeWithSelector is a middleware that allows us to use the selector in the subscribe function. Compared with the traditional subscribe of redux, it can be targeted for monitoring:

mport { subscribeWithSelector } from 'zustand/middleware'
const useStore = create(subscribeWithSelector(() => ({ paw: true, snout: true, fur: true })))

// Listening to selected changes, in this case when "paw" changes
const unsub2 = useStore.subscribe(state => state.paw, console.log)
// Subscribe also exposes the previous value
const unsub3 = useStore.subscribe(state => state.paw, (paw, previousPaw) => console.log(paw, previousPaw))
// Subscribe also supports an optional equality function
const unsub4 = useStore.subscribe(state => [state.paw, state.fur], console.log, { equalityFn: shallow })
// Subscribe and fire immediately
const unsub5 = useStore.subscribe(state => state.paw, console.log, { fireImmediately: true })

Later, there are some combined middleware, immer, localstorage, redux like, devtools and combime store. I won't go into detail. They are all some detailed scenarios. It is worth mentioning that all properties are orthogonal.

intensive reading

In fact, most usage features use React syntax, so it can be said that 50% of the features belong to React general features, but they are written in zustand In the document, it looks like the feature of zustand, so this library really can borrow.

Create store instance

Any data flow management tool has a core store instance. For zustand, it is defined in vanilla The createStore of the TS file.

createStore returns a data management instance similar to redux store, which has four very common API s:

export type StoreApi<T extends State> = {
  setState: SetState<T>
  getState: GetState<T>
  subscribe: Subscribe<T>
  destroy: Destroy
}

First, the implementation of getState:

const getState: GetState<TState> = () => state

It's so simple and rough. Look at state, which is a common object:

let state: TState

This is the simple side of data flow. There is no magic. Data storage uses an ordinary object, that's all.

Next, look at setState, which does two things: modify the state and execute the listener:

const setState: SetState<TState> = (partial, replace) => {
  const nextState = typeof partial === 'function' ? partial(state) : partial
  if (nextState !== state) {
    const previousState = state
    state = replace ? (nextState as TState) : Object.assign({}, state, nextState)
    listeners.forEach((listener) => listener(state, previousState))
  }
}

Modifying the state is also very simple. The only important thing is the listener (state, previous state). When were these listeners registered and declared? Actually, listeners is a Set object:

const listeners: Set<StateListener<TState>> = new Set()

When the registration and destruction times are called by the subscribe and destroy functions respectively, this implementation is very simple and efficient. The corresponding code will not be posted. Obviously, the listening function registered during subscribe will be added to the listeners queue as a listener, and will be called when setState occurs.

Finally, let's look at the definition and end of createStore:

function createStore(createState) {
  let state: TState
  const setState = /** ... */
  const getState = /** ... */
  /** ... */
  const api = { setState, getState, subscribe, destroy }
  state = createState(setState, getState, api)
  return api
}

Although this state is a simple object, reviewing the usage documents, we can create a store in create and assign a value to the state with callback. At that time, set, get and api were passed in the penultimate line of the above code:

import { create } from 'zustand'

const useStore = create((set, get) => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 })
}))

So far, the context of all API s for initializing the store has been sorted out, and the logic is simple and clear.

Implementation of create function

We have made it clear how to create a store instance, but this instance is the underlying API, using the create function described in the document in React TS file definition, and called createStore to create a framework independent data stream. Create is defined in React TS, because the returned useStore is a Hooks, so it has the feature of React environment, so it is named.

The first line of this function calls createStore to create the basic store. Because it is an internal API for the framework, it is also named API:

const api: CustomStoreApi = typeof createState === 'function' ? createStore(createState) : createState

const useStore: any = <StateSlice>(
  selector: StateSelector<TState, StateSlice> = api.getState as any,
  equalityFn: EqualityChecker<StateSlice> = Object.is
) => /** ... */

Next, all the codes are creating the useStore function. Let's look at its internal implementation:

Simply put, it is to use subscribe to monitor changes, forcibly refresh the current component when necessary, and pass in the latest state to useStore. So the first step, of course, is to create the forceUpdate function:

const [, forceUpdate] = useReducer((c) => c + 1, 0) as [never, () => void]

Then get the state by calling the API and pass it to the selector, and call equalityFn (this function can be customized) to judge whether the state has changed:

const state = api.getState()
newStateSlice = selector(state)
hasNewStateSlice = !equalityFn(
  currentSliceRef.current as StateSlice,
  newStateSlice
)

If the status changes, update currentsliceref current:

useIsomorphicLayoutEffect(() => {
  if (hasNewStateSlice) {
    currentSliceRef.current = newStateSlice as StateSlice
  }
  stateRef.current = state
  selectorRef.current = selector
  equalityFnRef.current = equalityFn
  erroredRef.current = false
})

useIsomorphicLayoutEffect is a common API routine for isomorphic frameworks. It is useLayoutEffect in the front-end environment and useEffect in the node environment:

Explain the functions of currentSliceRef and newStateSlice. Let's look at the last return value of useStore:

const sliceToReturn = hasNewStateSlice
  ? (newStateSlice as StateSlice)
  : currentSliceRef.current
useDebugValue(sliceToReturn)
return sliceToReturn

The discovery logic is as follows: if the state changes, the new state is returned, otherwise the old state is returned. This can ensure that when the compare function judges equality, the references of the returned objects are exactly the same. This is the core implementation of immutable data. In addition, we can also learn the skills of reading the source code, that is, we should often skip reading.

So how to update the store when the selector changes? There is also a core code in the middle, which calls subscribe. I'm sure you've guessed. The following is the core code fragment:

useIsomorphicLayoutEffect(() => {
  const listener = () => {
    try {
      const nextState = api.getState()
      const nextStateSlice = selectorRef.current(nextState)
      if (!equalityFnRef.current(currentSliceRef.current as StateSlice, nextStateSlice)) {
        stateRef.current = nextState
        currentSliceRef.current = nextStateSlice
        forceUpdate()
      }
    } catch (error) {
      erroredRef.current = true
      forceUpdate()
    }
  }
  const unsubscribe = api.subscribe(listener)
  if (api.getState() !== stateBeforeSubscriptionRef.current) {
    listener() // state has changed before subscription
  }
  return unsubscribe
}, [])

This code starts with the API Subscribe (listener). This makes any setState trigger the listener's execution, and the listener uses the API Getstate () gets the latest state, and gets the last compare function equalityFnRef to judge whether the value has changed before and after execution. If so, update currentSliceRef and perform a forced refresh (call forceUpdate).

Implementation of context

Note the context syntax. You can create multiple store instances that do not interfere with each other:

import create from 'zustand'
import createContext from 'zustand/context'

const { Provider, useStore } = createContext()

const createStore = () => create(...)

const App = () => (
  <Provider createStore={createStore}>
    ...
  </Provider>
)

First of all, we know that the stores created by create do not interfere with each other. The problem is that there is only one instance of useStore returned by create, and there is no scope declared by < provider >, so how to construct the above API?

First, the Provider stores the useStore returned by create:

const storeRef = useRef<TUseBoundStore>()
storeRef.current = createStore()

In fact, useStore does not realize the data flow function, but takes and returns the storeRef provided by < provider >:

const useStore: UseContextStore<TState> = <StateSlice>(
  selector?: StateSelector<TState, StateSlice>,
  equalityFn = Object.is
) => {
  const useProviderStore = useContext(ZustandContext)
  return useProviderStore(
    selector as StateSelector<TState, StateSlice>,
    equalityFn
  )
}

So the core logic is still in the create function, context TS only uses ReactContext to "inject" useStore into components, and uses ReactContext feature. Multiple instances of this injection can exist without mutual influence.

middleware

In fact, middleware does not need to be implemented. For example, take this example of redux middleware:

import { redux } from 'zustand/middleware'
const useStore = create(redux(reducer, initialState))

You can change the usage of zustand to reducer. In fact, it makes use of the functional concept. The redux function itself can get set, get and API. If you want to keep the API unchanged, just return callback as it is. If you want to change the usage, return a specific structure, which is so simple.

To deepen our understanding, let's take a look at the source code of redux middleware:

export const redux = ( reducer, initial ) => ( set, get, api ) => {
  api.dispatch = action => {
    set(state => reducer(state, action), false, action)
    return action
  }
  api.dispatchFromDevtools = true
  return { dispatch: (...a) => api.dispatch(...a), ...initial }
}

Encapsulate set, get and API as redux API: dispatch essentially calls set.

summary

zustand It is an exquisitely implemented React data flow management tool. Its own framework independent layering is reasonable, and the implementation of middleware is ingenious, which is worth learning.

The discussion address is: Intensive reading of zustand source code · Issue #392 · DT Fe / weekly

If you want to participate in the discussion, please click here , there are new themes every week, published on weekends or Mondays. Front end intensive reading - help you filter reliable content.

Focus on front-end intensive reading WeChat official account

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

Copyright notice: free reproduction - non commercial - non derivative - keep signature( Creative sharing 3.0 License)

Keywords: Javascript Front-end

Added by aisalen on Thu, 27 Jan 2022 05:24:32 +0200