Using Zustand as a state management library

Mert Genç
Koçfinans Tech
Published in
6 min readDec 28, 2022

Hi, friends.

In this article, I’m going to explain one of the best state management library for React projects. It’s Zustand. It means “state” in German :)

Firstly, zustand created by developers of jotai and React-Spring. Before the implementation of that, I want to talk about some features about Zustand.

Features:

  • Compared to others, zustand is very simple and readable. It easy to use and install.
  • Zustand is modern. It uses modern React Hooks API.
  • Less boilerplate code
  • Providing clean code architecture
  • Fully written typescript. Which makes it typesafe.
  • It’s easy to write test cases
  • It supports micro states
  • Don’t need to wrap your app bunch of providers unlike Context API, Redux

Setting up an app

Create a react app with CRA:

yarn create react-app zustand-example --template typescript
cd zustand-example
yarn add zustand

While using zustand, you don’t need to use providers. Your state is a hook:

// useCounterState.ts
import create from "zustand";
type CounterStore = {
count: number;
increment: () => void;
decrement: () => void;
};
// using the create with generics
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
// import { combine } from 'zustand/middleware'
// using the combine api to combine multiple stores
// export const useCounterStore = create(
// combine({ count: 0 as number }, (set) => ({
// increment: () => set((state) => ({ count: state.count + 1 })),
// decrement: () => set((state) => ({ count: state.count - 1 })),
// }))
// );

After the creating your state setup, you can easily access your state:

import { useCounterStore } from "../store/useCounterStore";
type Props = {};
const Counter: React.FC<Props> = (props) => {
const counterStore = useCounterStore();
return (
<div className="counter-container">
<h1>Zustand API Example ✨</h1>
<div>
<h1 className="counter-title">Counter: {counterStore.count}</h1>
<button className="counter-button" onClick={counterStore.increment}>
+
</button>
<button className="counter-button" onClick={counterStore.decrement}>
-
</button>
</div>
</div>
);
};
export default Counter;

Zustand has an amazing type system. You’re able to write an interface or type and put it into diamond operators. Another option is, creating your store with zustand/combine API. Combine API will infer types from your state object.

// useCounterState.ts
import create from "zustand";

import { combine } from 'zustand/middleware'
// using the combine api to combine multiple stores
export const useCounterStore = create(
combine({ count: 0 as number }, (set) => ({
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
);

using the combine API

using the combine API inferring types from state object

Nested States

You can use nested state tree with immer:

type State = {
deep: {
nested: {
obj: { count: number };
};
};
increment: () => void;
};

you need to merge state:

import { produce } from "immer";

const useNestedObjectStore = create<State>((set) => ({
deep: {
nested: {
obj: {
count: 0,
},
},
},
increment: () =>
set(
produce((draft: State) => {
draft.deep.nested.obj.count++;
})
),
}));

Persisting States

With zustand you can easily persist state with persist middleware. All you need to do is use persist function:

import create from "zustand";
import { persist } from "zustand/middleware";

type CreditStore = {
credit: number;
increaseCredit: () => void;
};

export const useCreditStore = create(
persist<CreditStore>(
(set, get) => ({
credit: 0,
increaseCredit: () => set({ credit: get().credit + 1 }),
}),
{
name: "new-key", // unique name for this store
getStorage: () => sessionStorage, // default is localStorage
}
)
);

changing key and storage is so easy. For example you can easily use React Native AsyncStorage with persist middleware.

getStorage: () => AsyncStorage, // default is localStorage

Using Redux-Like Actions

If you are used to using redux, keep calm and use zustand:

import create from "zustand";

type NameStore = {
firstName: string;
lastName: string;
};

type Action = {
updateFirstName: (firstName: string) => void;
updateLastName: (lastName: string) => void;
};

export const useNameStore = create<NameStore & Action>((set) => ({
firstName: "John",
lastName: "Doe",
updateFirstName: (firstName: string) => set({ firstName }),
updateLastName: (lastName: string) => set({ lastName }),
}));

or:

const types = { increase: 'INCREASE', decrease: 'DECREASE' }

const reducer = (state: , { type, by = 1 }): => {
switch (type) {
case types.increase:
return { count: state.count + by }
case types.decrease:
return { count: state.count - by }
}
}

const useStore = create((set) => ({
count: 0,
dispatch: (args) => set((state) => reducer(state, args)),
}))

const dispatch = useStore((state) => state.dispatch)
dispatch({ type: types.increase, by: 2 })

Using Devtools

As in redux or react-query you can use devtools as middleware:

import create from "zustand";
import { devtools } from "zustand/middleware";

type DevtoolsStore = {
showDevtools: boolean;
setShowDevtools: (showDevtools: boolean) => void;
};

const useDevtoolsStore = create(
devtools<DevtoolsStore>( // using devtools middleware
(set) => ({
showDevtools: false,
setShowDevtools: (showDevtools: boolean) => set({ showDevtools }),
}),
{
enabled: process.env.NODE_ENV === "development",
}
)
);

export default useDevtoolsStore;

Async actions

Unlike redux or context API you don’t need to add any boilerplate code to using async actions. You can use normal javascript functions:

import create from "zustand";

const user = {
id: 1,
name: "Leanne Graham",
username: "Bret",
email: "Sincere@april.biz",
address: {
street: "Kulas Light",
suite: "Apt. 556",
city: "Gwenborough",
zipcode: "92998-3874",
geo: {
lat: "-37.3159",
lng: "81.1496",
},
},
phone: "1-770-736-8031 x56442",
website: "hildegard.org",
company: {
name: "Romaguera-Crona",
catchPhrase: "Multi-layered client-server neural-net",
bs: "harness real-time e-markets",
},
};

type User = typeof user;

interface UserStore {
user?: User;
setUser: (user: User) => void;
loading: boolean;
fetchUser: () => Promise<void>;
hasError: boolean;
}

export const useUserStore = create<UserStore>((set) => ({
user: user,
setUser: (user: User) => set({ user }),
fetchUser: async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/1"
);
const user = await response.json();
set({ user });
} catch (error) {
set({ hasError: true });
} finally {
set({ loading: false });
}
},
loading: false,
hasError: false,
}));

That’s so easy and readable.

Accessing the state outside of components

In zustand you can access state outside of component and even you can update your state outside of component.

For example, in this case let’s assume you need to access your accessToken inside state object and add to http header.

import create from "zustand";
import { combine } from "zustand/middleware";
import axios from "axios";

const useAuthStore = create(
combine({ token: "" }, (set) => ({
setToken: (token: string) => set({ token }),
logout: () => set({ token: "" }),
}))
);

const client = axios.create({
baseURL: "http://localhost:3000",
headers: {
"Content-Type": "application/json",
},
});

const injectToken = client.interceptors.request.use(
async (config) => {
const token = useAuthStore.getState().token;
config.headers.set("Authorization", `Bearer ${token}`);
return config;
},
(error) => {
return Promise.reject(error);
}
);

const responseInterceptor = client.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(error);
}
);

This is so simple and readable. Also you can subscribe to store:

const useAuthStore = create(
subscribeWithSelector(
combine({ token: "" }, (set) => ({
setToken: (token: string) => set({ token }),
logout: () => set({ token: "" }),
}))
)
);

You can subscribe a field with subcribeWithSelector API.

const unsub1 = useAuthStore.subscribe(
(state) => state.token,
(token) => {
console.log("token", token);
// do something with token
}
);

unsub(); // unsubscribe
// or
useAuthStore.destroy(); // unsubscribe all

As you see it’s so simple to manage states with zustand. If you want to see more detail docs, you can read from here.

See you next article!

--

--