Zustand: An innovative state management solution for React

Featured on Hashnode

State management is one of the core concepts of React JS. However, if you have been working with React for quite some time, you know that managing state in plain React components (wether by using class components or hooks) can quickly become cumbersome. So, using a state management solution becomes a necessity.
Today, I would like to introduce you one such state management solution, which I fell in love with as soon as I discovered: Zustand
The first thing that piqued my interest is it's hook-based api. Storing and retrieving state has never been easier. all you need to access state is a hook, no matter the scale of your application. It is also small, fast, unopinionated and very comfortable to work with, as we shall see.
I'm sure you will come to love it. Let's get started!

What we'll Learn

In this blog post, we will explore Zustand by -

  • creating a store to keep track of state variables
  • accessing state
  • modifying state
  • using multiple stores
  • handling asynchronous actions
  • debugging with Redux devtools
  • next steps

What we're building

We will be adding functionality to a simple scoreboard application as we explore Zustand. I have developed the ui components so that we have a head start. To get started -

  1. Clone the repository
     git clone https://github.com/gmahima/NextWithZustand
    
  2. Switch to the initial branch
     git checkout initial
    
  3. install the dependencies
     yarn
    
  4. Start the server and head over to localhost:3000
    yarn dev
    
    Or, you can fork the following sandbox:

Let's take a quick look at the tech stack:

Alright! Let's get started.

Creating a Store

To maintain state in Zustand, we use a store. Let us use it to maintain list of players in state. Create a root-level directory called stores and add the file usePlayerStore.js.

We can create a store using the create function exported by Zustand.

The create function takes in the description of the state as argument and returns a hook which we can call anywhere in the app to access state!

  1. Import the create function from zustand.
     import create from 'zustand'
    
  2. Pass it a callback function which returns the structure of the state. Now, export the returned hook so we can use it to access state wherever we need it.

     // stores/usePlayerStore.js
     import create from 'zustand'
    
     const initialPlayers = [
         {
             id: "1",
             score: 0,
             name: "harry"
         },
         {
             id: "2",
             score: 0,
             name: "ron"
         }
     ] // initial players array
    
     const store = () => ({
         players: initialPlayers
     }) 
     // a callback function which returns an object
    // describing state
    export const usePlayerStore = create(store) 
    // storing the returned hook as usePlayerStore
    

Accessing State

Let us access state to get the current players in the app. Head over to pages/index.js

  1. Import the usePlayerStore hook from stores/usePlayerStore.
    import {usePlayerStore} from '../stores/usePlayerStore'
    
  2. Call this hook and ask for the specific slice of state you want. Here, we need the players array from the store. So, we can specifically ask for it instead of the getting entire state in the store which might contain other state variables irrelevant to the component.
    Let us access state from the Home function (Remember to delete the hard-coded array from earlier).

    import {usePlayerStore} from '../stores/usePlayerStore'
    
    export default function Home() {
     const players = usePlayerStore(state => state.players)
     // ...
    }
    

    In localhost:3000 you will notice that the app works just like before, except, now it is fetching the players stored in the state, instead of hard-coded values.

  3. Since the selector doesn't depend on the scope of the function, let us define it outside to prevent unnecessary computations on each render. (If it were, we would have used useCallback for memoizing it).
    import {usePlayerStore} from '../stores/usePlayerStore'
    const getState = state => state.players
    export default function Home() {
     const players = usePlayerStore(getState)
     // ...
    }
    

Well done! We have successfully created a store and accessed state from a React component.

Modifying State

Let us write a simple function to change a player's score.

The callback function passed to create will have access to the get and set methods which we can use to read and modify state respectively.

We can define functions as properties of the store alongside the state variables.
The chagePlayerScore function will receive the player's id whose score is to be changed and direction(up/down). Let us pass this data along withset, get to a handler function, handleChangePlayerScore.

  const playerStore = (set,get) => ({
      players: initialPlayers,
      changePlayerScore: ((id, dir) => {handleChangePlayerScore(id, dir, get, set)})
  })

Let us define handleChangePlayerScore:

  1. We can get access to the properties of the store using the get method. Let us call it and extract the players array.
    const handleChangePlayerScore = (id, dir, get, set) => {
     const players = get().players
    }
    
  2. Let us define the logic for changing score:
    const handleChangePlayerScore = (id, dir, get, set) => {
     // ...
     const n = [...players]
     let player = n.find(p => p.id === id)
     if(dir === 'up') {
         player.score += 10;
     }
     else {
         player.score -= 10;
     }
    }
    
  3. We can now update the array stored in state using the set method like so:
    const handleChangePlayerScore = (id, dir, get, set) => {
     // ...
     set({players: n})
    }
    
  4. While we are here, let us also track the high score:
    const playerStore = (set,get) => ({
       players: initialPlayers,
       highScore: 0,
       setHighScore: (s => set({highScore: s})),
       changePlayerScore: ((id, dir) => {handleChangePlayerScore(id, dir, get, set)})
    })
    
    Let us update the handleChangePlayerScore function to change the highScore variable whenever a player achieves a new high score.
    // in handleChangePlayerScore
    if(dir === 'up') {
         player.score += 10
         if(player.score> get().highScore) {
             set({highScore: player.score})
         }
    }
    

Great! Now we can use handleChangePlayerScore to change a player's score (and thehighScore) from our components!

  1. Head over to components/Player.js. We can get this method just like the players array earlier.

     import usePlayerStore from '../stores/usePlayerStore'
    
     const getChangePlayerScore = state => state.changePlayerScore
     export default function Player ({player}) {
         const changePlayerScore = usePlayerStore(getChangePlayerScore)
         // ...
     }
    
  2. Similarly, we can use the hook again to access the highScore variable this time and wire them to the buttons and icons.
     const getChangePlayerScore = state => state.changePlayerScore
     const getHighScore =  state => state.highScore
     export default function Player ({player}) {
         const changePlayerScore = usePlayerStore(getChangePlayerScore)
         const highScore = usePlayerStore(getHighScore)
         return(
             // ...
            <StyledStar hasHighScore={player.score === highScore && highScore>0} />
            // ...
           <button onClick={() => {changePlayerScore(player.id, "up")}}>{/*...*/}</button>
           <button onClick={() => {changePlayerScore(player.id, "down")}}>{/*...*/}   </button>
            // ...
         )
     }
    
  3. Let us also display the current highScore by rendering it from the Home component in pages/index.js. Now, we could do this by calling the hook again and extracting the highScore variable
    OR -
    We can retrieve multiple state slices and store them in an object (like with Redux). However, by default, changes are detected using strict equality(===) which is efficient for atomic state picks. When we extract multiple state slices into an object we can provide it an alternate equality function shallow which is more efficient.
    import shallow from 'zustand/shallow'
    const getState = state => ({players: state.players, highScore: state.highScore}) 
    export default function Home() {
     const {players, highScore, test, setTest} = usePlayerStore(getState, shallow)
     //...
       return (
         // ...
         <h2>High Score: {highScore}</h2>
         // ...
       )
    }
    

Let us now take a look at the app: localhost:3000

Screenshot 2020-12-08 at 9.06.19 PM.png We can now change the players' scores and track the highest score recorded. Awesome!

Using Multiple stores

Notice that in components/Player.js there is an array called vips

 const vips = ['2','5']

If a player'sid is present in this array, the VIP icon is displayed. Suppose we want to track this information and enable addition / deletion to the list by some component. We would want it to be kept separate from the original store since it is irrelevant to the scoreboard logic. To support such cases

we can create multiple stores in Zustand.

Let us see it in action:

  1. Create a new file useVipStore in the stores directory and create a store just like before. Now, assign the vips array to a property in the store:
    export const useVipStore = create(set => ({
     vips: ['10', '2', '5']
    }))
    
  2. Now, head over to components/Player.js and use this store to access vips.
    import {useVipStore} from '../stores/useVipStore'
    export default function Player ({player}) {
     const vips = useVipStore(state => state.vips)
     // ...(delete the previously hard-coded array)
    }
    

The app works just like before, but now vips is a state variable being accessed from a store.

Handling asynchronous actions

Handling asynchronous actions pretty simple with Zustand. We can declare functions with the async keyword and call set whenever we get the data:

  1. I used typicode's my-json server to create a fake rest api to store a list of players. Let us use it in our app.
  2. Let us create a new function loadPlayers and use the data from the server. You can copy the url from below.

    // stores/usePlayerStore.js
    const Url = 'https://my-json-server.typicode.com/gmahima/NextWithZustand/players'
    // ...
    const playerStore = (set,get) => ({
     players: initialPlayers,
     loadPlayers: async () => { await handleLoadPlayers(set, get)}
     // ...
    })
    

    In handleLoadPlayers, let us fetch the data and assign it toplayers via set when it is ready.

    const handleLoadPlayers = async (set, get) => {
     fetch(Url)
     .then(res => res.json())
     .then(players => {
       players.map(p => p.score = 0)
       set({players: players})
    
     })
     .catch(error =>{console.log(error)})
    }
    
  3. Let us load the players when the app is mounted. Go to _app.js and call loadPlayers on mount:
    import usePlayerStore from '../stores/usePlayerStore'
    // ...
    function MyApp({ Component, pageProps }) {
     const loadPlayers = usePlayerStore(state => state.loadPlayers)
     useEffect(() => {
       loadPlayers()
     }, [])
    // ...
    }
    

Let us check how the app looks now: Screenshot 2020-12-08 at 11.11.04 PM.png The app loads the players from the server. Nice!

Debugging with Redux devtools

We can use Redux devtools to debug our app by using the devtools middleware Zustand provides.

You simply need to import the middleware and pass it the store like so:

import { devtools } from 'zustand/middleware'

const usePlayerStore = create(devtools(playerStore))

Screenshot 2020-12-08 at 11.30.34 PM.png

Completing the app

Let us implement the add player functionality:

  1. In the playerStore, create a function addPlayer. Copy the logic for storing it back to the json-server from below:

    const storePlayer = async (p) => {
     await fetch(Url, {
       method: 'POST',
       body: JSON.stringify(p),
       headers: {
         'Content-Type': 'application/json'
       },
       credentials: 'same-origin'
    
     }).then(res => {
         if(res.ok) {
             return res
         }
         else {
             let err = res.json()
             err.res = res;
             throw err; 
         }
     }, error => {
         console.log("error: ", error)
         let errmes = new Error(error.message)
         throw(errmes)
     })
     .then(res => res.json())
     .then(res => console.log(res))
     .catch(error =>{console.log(error)})
    } // stores the player in json-server
    
    const handleAddPlayer = async (set, get, name) =>{
     let n = [...get().players]
     let p ={}
     p.name = name
     p.id = (n.length+1).toString();
     await storePlayer(p).then(() => {
       p.score = 0;
       n.push(p)
       set({players: n})
     })
     .catch(e => console.log(e))
    }
    
    const playerStore = (set,get) => ({
     loadPlayers: async () => { await handleLoadPlayers(set, get)},
     // ...
    })
    
  2. Head over to components/AddPlayer.js. Here, import the usePlayerStore hook and call the addPlayer method:
     const getAddPlayer = state => state.addPlayer
     export default function AddPlayerForm() {
         // ...
         const addPlayer = usePlayerStore(getAddPlayer)
         const handleSubmit = (e => {
             // ...
             addPlayer(val)
             // ...
         })
         // ...
     }
    

Great work! Our app looks amazing!

You can check out the code for the completed app here.

Next Steps

Remember that we have just barely scraped the surface here. You can do a lot more with Zustand. For example, we can use Zustand without React, read/write state or react to state outside React components, use multiple middleware hassle-free and compose store any way you like. Be sure to read their docs here.

To Recap

  • Zustand is a fast and hassle-free hook-based state management solution for React.
  • We manage state with Zustand using a store.
  • We can access state by calling a custom hook and retrieve state in any format we are comfortable with.
  • We can create as many stores as we like.
  • Asynchronous actions can be performed by just declaring the methods as async and calling set whenever the data is ready
  • We can debug Zustand using Redux devtools with a middleware.
  • It dosen't stop there! Zustand is meticulously developed and offers a ton of other cool features.

That's a wrap! I hope this was useful and that you will try Zustand in your projects. Do drop a comment and take some time to share it in your community. Thanks!