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 managing global client-side state. 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 throughout your application?

Sometimes, I think it is. 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 within 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:

Initializing via Props#

A global state store is created outside the React component lifecycle, so we cannot initialize our state 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 unnecessary re-renders.
  2. We don't need to initialize our state store with initialBears - we only use it for synchronization. So if initialBears changes, we will see our state store being unnecessarily updated.

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, among other things. I believe 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 a zustand state store to be reusable 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 problems:

React Context#

React Context is the solution that seems funny and ironic here because 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 - through the components.

Conceptually, this is what React Query does with <QueryClientProvider>, and Redux does with its single state store. Because the state store instance is a static singleton that doesn't change frequently, it is very easy and doesn't cause re-renders to put them in React Context. And we can still create subscribers to the state store that are 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 is better suited for 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 solve this, but I prefer using useState. If you're curious why, I have a separate article dedicated to that here.

Because we are creating the state store inside a component, we can stop passing props like initialBears to createStore as the actual initial value. The initialization method of useState is only called once, so updates to the prop won't 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 that, 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 with some atomic selectors:

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

It adds a bit more code 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 create 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, achieving reusability.

Finally, even though the zustand documentation proudly states that there is no need for a Context Provider to access a state store, I believe it is worth knowing how to integrate the creation of a state store with React Context. It allows you to handle encapsulation and reusability in certain 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.