banner
Jim Luo

Jim Luo

A normal software engineer and an enthusiast in computer graphics and data visualization.
twitter
github
bilibili
nintendo switch
playstation

State and React Context

This article is a translation of the original article. Please let me know if there are any omissions or corrections.

The zustand bear logo sitting under an arch

Zustand is a great library for global client-side state management. It is simple, fast, and has a small package size. However, there is one thing I don't really like:

The state store is global.

Well, isn't that the whole point of global state management? To make your state available everywhere in your application?

Sometimes, I think that's true. However, when I look back at my experience using zustand in recent years, I often realize that I need some state to be globally available only within a component subtree, not the entire application. With zustand, it is entirely possible - even encouraged - to create multiple small stores based on functionality. So if I only need the state of a panel filter to be used in a panel route, why do I need my panel filter store to be globally available? Sure, I can use it without any issues, but I have found some drawbacks to using a global state store:

Initialization via Props#

A global state store is created outside the React component lifecycle, so we cannot initialize our store with a value from a prop. In a global state store, we need to create it with a known default state and then use useEffect to synchronize the state from props to the store:

const useBearStore = create((set) => ({
  // ⬇️ initialize with default value
  bears: 0,
  actions: {
    increasePopulation: (by) =>
      set((state) => ({ bears: state.bears + by })),
    removeAllBears: () => set({ bears: 0 }),
  },
}))

const App = ({ initialBears }) => {
  //😕 write initialBears to our store
  React.useEffect(() => {
    useBearStore.set((prev) => ({ ...prev, bears: initialBears }))
  }, [initialBears])

  return (
    <main>
      <RestOfTheApp />
    </main>
  )
}

Apart from not wanting to write the useEffect, there are two reasons why this approach is not ideal:

  1. When we initially render <RestOfTheApp /> with bears: 0 before the correct initialBears value is assigned, it causes multiple renders.
  2. We don't need to initialize our store with initialBears - we only use it for synchronization. So if initialBears changes, we will see our store being updated unnecessarily.

Testing#

I found the testing documentation for zustand to be confusing and complex. The tests are all about mocking zustand and resetting the state store, etc. I think all of this stems from the fact that the state store is global. If it were limited to a component subtree, we could render the components we want to test while keeping their state stores isolated, without the need for any of these "workarounds".

Reusability#

Not all state stores are singletons that we use once in our application or a specific route. Sometimes, we want to be able to reuse a zustand state store within a component. One example is a complex, multi-select component we designed in the past. It used local state managed by React Context to handle the internal state of the multi-select. It became slow when there were 50 or more options selected. It prompted me to tweet about it here.

If a zustand state store is global, we won't be able to instantiate the component multiple times without sharing and overriding each other's state.


Interestingly, there is a solution to all these issues:

React Context#

React Context is the solution that seems funny and ironic in this context, as using Context as a state management tool immediately brings up the aforementioned issues. But that's not what I'm recommending. Instead, we are using Context to share the state store instance - not the state itself.

Conceptually, this is similar to how React Query works with the <QueryClientProvider> or how redux works with its single state store. Because the state store instance is a static singleton that doesn't change frequently, it is easy and doesn't cause unnecessary re-renders to put them in React Context. And we can still create subscribers for the state store, which will be optimized by zustand. Here's what the implementation looks like:

v5 syntax
In this article, I will show the v5 syntax for integrating zustand and React Context. In previous versions, zustand had a dedicated createContext function exported from zustand/context.

import { createStore, useStore } from 'zustand'

const BearStoreContext = React.createContext(null)

const BearStoreProvider = ({ children, initialBears }) => {
  const [store] = React.useState(() =>
    createStore((set) => ({
      bears: initialBears,
      actions: {
        increasePopulation: (by) =>
          set((state) => ({ bears: state.bears + by })),
        removeAllBears: () => set({ bears: 0 }),
      },
    }))
  )

  return (
    <BearStoreContext.Provider value={store}>
      {children}
    </BearStoreContext.Provider>
  )
}

The main difference is that we are not using the out-of-the-box create function to create the instance. Instead, we rely on the pure zustand createStore function, which provides better control over creating a state store. And we can do this anywhere - even inside a component. However, we need to ensure that the creation of the state store only happens once. We can use a ref to achieve this, but I prefer using useState. If you're curious why, I have a separate article dedicated to explaining it.

Since we are creating the state store inside a component, we can stop using props like initialBears and pass them directly as the true initial value to createStore. The initialization method of useState is only called once, so updates to the prop will not be passed to the state store. Then, we instantiate the state store and pass it to a simple React Context. There are no constraints from zustand here.


Afterwards, whenever we want to consume some values from the state store, we will use this context. For this, we need to pass the store and selector to the useStore hook we get from zustand. Here's the best abstraction for a corresponding custom hook:

const useBearStore = (selector) => {
  const store = React.useContext(BearStoreContext)
  if (!store) {
    throw new Error('Missing BearStoreProvider')
  }
  return useStore(store, selector)
}

Then, we can use the useBearStore hook as before and export custom hooks that extract specific values from it using atomic selectors:

export const useBears = () => useBearStore((state) => state.bears)

There is a bit more code involved compared to creating a global state store, but it solves three problems:

  1. As shown in the example, we can use props to initialize our state store because we are creating it from within the React component tree.
  2. Testing becomes a breeze because we can choose to render a component with the BearStoreProvider or we can render a component specifically for testing. In these scenarios, the pre-created state store is completely isolated for testing, so there is no need to reset the state store between tests.
  3. Now a component can render a BearStoreProvider to provide a wrapped zustand state store to its child components. We can freely render this component anywhere on a page - each instance will have its own independent state store, allowing for reusability.

Finally, even though the zustand documentation proudly states that there is no need for a Context Provider to access a state store, I think it is worth knowing how to integrate the creation of a state store with React Context. This allows you to handle encapsulated and reusable scenarios with ease. Personally, I use this abstraction more often than a global zustand state store. 😄


That's what I wanted to talk about today. If you have any questions, feel free to reach out to me on Twitter or leave a comment below. ⬇️

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.