Zustand: An innovative state management solution for React
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 -
- Clone the repository
git clone https://github.com/gmahima/NextWithZustand
- Switch to the
initial
branchgit checkout initial
- install the dependencies
yarn
- Start the server and head over to localhost:3000
Or, you can fork the following sandbox:yarn dev
Let's take a quick look at the tech stack:
- This site is built with Next.js - an awesome React framework.
- Styled with Tailwindcss + styled-components using twin.macro
- Uses styled-icons for the icons.
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!
- Import the
create
function fromzustand
.import create from 'zustand'
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
- Import the
usePlayerStore
hook fromstores/usePlayerStore
.import {usePlayerStore} from '../stores/usePlayerStore'
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 theHome
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.
- 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 theget
andset
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
:
- 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 }
- 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; } }
- We can now update the array stored in state using the
set
method like so:const handleChangePlayerScore = (id, dir, get, set) => { // ... set({players: n}) }
- While we are here, let us also track the high score:
Let us update theconst playerStore = (set,get) => ({ players: initialPlayers, highScore: 0, setHighScore: (s => set({highScore: s})), changePlayerScore: ((id, dir) => {handleChangePlayerScore(id, dir, get, set)}) })
handleChangePlayerScore
function to change thehighScore
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!
Head over to
components/Player.js
. We can get this method just like theplayers
array earlier.import usePlayerStore from '../stores/usePlayerStore' const getChangePlayerScore = state => state.changePlayerScore export default function Player ({player}) { const changePlayerScore = usePlayerStore(getChangePlayerScore) // ... }
- 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> // ... ) }
- Let us also display the current
highScore
by rendering it from theHome
component inpages/index.js
. Now, we could do this by calling the hook again and extracting thehighScore
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 functionshallow
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
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:
- Create a new file
useVipStore
in thestores
directory and create a store just like before. Now, assign thevips
array to a property in the store:export const useVipStore = create(set => ({ vips: ['10', '2', '5'] }))
- Now, head over to
components/Player.js
and use this store to accessvips
.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:
- 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.
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
viaset
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)}) }
- Let us load the players when the app is mounted. Go to
_app.js
and callloadPlayers
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: 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))
Completing the app
Let us implement the add player functionality:
In the
playerStore
, create a functionaddPlayer
. 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)}, // ... })
- Head over to
components/AddPlayer.js
. Here, import theusePlayerStore
hook and call theaddPlayer
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 callingset
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!