Skip to main content

State management (Zustand)

In this chapter, you learn "Zustand," a popular state management library in React development.

What you learn in this chapter

  • Basic usage of Zustand
  • Re-render optimization with selectors
  • Middleware (persist, devtools)
  • How to choose between it and useContext
info

This chapter uses the project created in Chapter 3. Start the dev server with npm run dev and learn while writing code.

Why Zustand

Options for state management

LibraryCharacteristic
useStateFor local state
useContextLightweight global state
ReduxFor large apps, complex setup
ZustandLightweight, simple API
JotaiAtomic state management

Zustand is the best option when you need global state management while avoiding Redux's complexity.

info

You can also implement global state with useContext (Chapter 13), but the main reasons to choose Zustand are as follows:

  • Re-render optimization: you can subscribe to only the parts you need with selectors
  • Less boilerplate: no Provider needed, complete in a single file
  • DevTools support: visualize state with Redux DevTools
  • Middleware: easily add persistence and logging

Setup

npm install zustand
info

This book uses Zustand v5 (5.0.14 at the time of writing). If your major version differs, the API may have changed.

warning

About Zustand v5's recommended TypeScript notation

The samples in this book write create<T>((set) => ...) for readability, but when also using v5 middleware, the curried form create<T>()(...) (a two-stage function call) is recommended for type inference reasons.

// Recommended: the curried form (stable type inference with middleware)
const useStore = create<CounterStore>()((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))

// When combining with persist
const useStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{ name: 'settings-storage' }
)
)

For a simple store that does not use middleware, the create<T>((set) => ...) shown in this book also works correctly.

info

Subscribe to only the values you need with a selector

If you pull out the whole store, as in const { count, increment } = useCounterStore(), this component re-renders whenever any field changes. If performance is a concern, pull out only the values you need with a selector.

// Select only the values you need (minimize re-renders)
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)

// When selecting multiple values, compare objects with useShallow
import { useShallow } from 'zustand/react/shallow'
const { count, increment } = useCounterStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
)

Basic usage

Creating a store

import { create } from 'zustand'

type CounterStore = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}

const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}))

Using it in a component

function Counter() {
const { count, increment, decrement, reset } = useCounterStore()

return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
)
}

Try it: create a counter store

Create a counter store with count, increment, decrement, and reset, and make it operable with buttons.

Hint
  1. Define the count state and update functions with the create function
  2. Update the state with the set((state) => ({ count: state.count + 1 })) pattern
  3. No Provider needed; usable from anywhere
Answer and explanation
// src/stores/counterStore.ts
import { create } from 'zustand'

type CounterStore = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}

export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}))
// src/App.tsx
import { useCounterStore } from './stores/counterStore'

function App() {
const { count, increment, decrement, reset } = useCounterStore()

return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Zustand counter</h1>
<p className="text-4xl font-bold mb-4">{count}</p>
<div className="space-x-2">
<button
onClick={decrement}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
-1
</button>
<button
onClick={increment}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
+1
</button>
<button
onClick={reset}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
>
Reset
</button>
</div>
</div>
)
}

export default App

Zustand's create function returns an object containing state and actions. You pass new state to the set function, or a function that computes new state from the previous state. It is usable from anywhere without a Provider.

Optimization with selectors

By subscribing to only the state you need, you can prevent unnecessary re-renders.

warning

If you pull out the whole object with destructuring, it re-renders whenever any value changes. If you care about performance, always use a selector.

// Subscribe to all state (re-renders when count changes)
const { count, increment } = useCounterStore()

// Subscribe to count only
const count = useCounterStore((state) => state.count)

// Subscribe to increment only (no re-render since the function does not change)
const increment = useCounterStore((state) => state.increment)

Selecting multiple values

// Subscribe to multiple values with a shallow comparison
import { useShallow } from 'zustand/react/shallow'

const { count, total } = useCounterStore(
useShallow((state) => ({
count: state.count,
total: state.total
}))
)

Try it: leverage selectors

Split the counter component into CountDisplay (display only) and CountButtons (operations only), and subscribe to only the values you need with selectors.

Hint
  1. Subscribe to count only with useCounterStore((state) => state.count)
  2. Action functions do not change, so they do not cause re-renders
  3. Add console.log to confirm re-renders
Answer and explanation
// src/components/CountDisplay.tsx
import { useCounterStore } from '../stores/counterStore'

export function CountDisplay() {
// Subscribe to count only (re-render only when count changes)
const count = useCounterStore((state) => state.count)

console.log('CountDisplay rendered')

return (
<div className="text-6xl font-bold text-center p-8 bg-gray-100 rounded-lg">
{count}
</div>
)
}
// src/components/CountButtons.tsx
import { useCounterStore } from '../stores/counterStore'

export function CountButtons() {
// Subscribe to actions only (no re-render since the functions do not change)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)

console.log('CountButtons rendered')

return (
<div className="flex gap-2 justify-center">
<button
onClick={decrement}
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
-1
</button>
<button
onClick={increment}
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
+1
</button>
<button
onClick={reset}
className="px-6 py-3 bg-gray-500 text-white rounded-lg hover:bg-gray-600"
>
Reset
</button>
</div>
)
}
// src/App.tsx
import { CountDisplay } from './components/CountDisplay'
import { CountButtons } from './components/CountButtons'

function App() {
return (
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-center">Selector optimization</h1>
<CountDisplay />
<CountButtons />
</div>
)
}

export default App

If you check the console, you can see that clicking the buttons does not re-render CountButtons, and only CountDisplay updates. Using selectors, only the parts you need are re-rendered.

Practical store design

A Todo app store

type Todo = {
id: string
text: string
completed: boolean
}

type TodoStore = {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: string) => void
removeTodo: (id: string) => void
clearCompleted: () => void
}

const useTodoStore = create<TodoStore>((set) => ({
todos: [],

addTodo: (text) => set((state) => ({
todos: [
...state.todos,
{
id: crypto.randomUUID(),
text,
completed: false
}
]
})),

toggleTodo: (id) => set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),

removeTodo: (id) => set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id)
})),

clearCompleted: () => set((state) => ({
todos: state.todos.filter((todo) => !todo.completed)
}))
}))

Usage

function TodoList() {
const todos = useTodoStore((state) => state.todos)
const toggleTodo = useTodoStore((state) => state.toggleTodo)
const removeTodo = useTodoStore((state) => state.removeTodo)

const createToggleHandler = (id: string) => () => {
toggleTodo(id)
}

const createRemoveHandler = (id: string) => () => {
removeTodo(id)
}

return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={createToggleHandler(todo.id)}
/>
<span className={todo.completed ? 'line-through' : ''}>
{todo.text}
</span>
<button onClick={createRemoveHandler(todo.id)}>Delete</button>
</li>
))}
</ul>
)
}

function AddTodo() {
const [text, setText] = useState('')
const addTodo = useTodoStore((state) => state.addTodo)

const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value)
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (text.trim()) {
addTodo(text)
setText('')
}
}

return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={handleTextChange}
placeholder="New Todo"
/>
<button type="submit">Add</button>
</form>
)
}

Asynchronous actions

type UserStore = {
user: User | null
loading: boolean
error: string | null
fetchUser: (id: string) => Promise<void>
}

const useUserStore = create<UserStore>((set) => ({
user: null,
loading: false,
error: null,

fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const response = await fetch(`/api/users/${id}`)
const user = await response.json()
set({ user, loading: false })
} catch (error) {
set({ error: 'Failed to fetch the user', loading: false })
}
}
}))

Middleware

persist (persistence)

import { persist } from 'zustand/middleware'

const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme })
}),
{
name: 'settings-storage' // the localStorage key
}
)
)

devtools (Redux DevTools integration)

import { devtools } from 'zustand/middleware'

const useStore = create<StoreType>()(
devtools(
(set) => ({
// ...
}),
{ name: 'MyStore' }
)
)

Combining middleware

const useStore = create<StoreType>()(
devtools(
persist(
(set) => ({
// ...
}),
{ name: 'storage-key' }
),
{ name: 'DevToolsName' }
)
)

Try it: add persist

Save the counter value to localStorage so that the value is retained even after reloading the page.

Hint
  1. Wrap create with the persist middleware
  2. Specify the localStorage key with the name option
  3. Confirm with "Application" → "Local Storage" in DevTools
Answer and explanation
// src/stores/counterStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type CounterStore = {
count: number
increment: () => void
decrement: () => void
reset: () => void
}

export const useCounterStore = create<CounterStore>()(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}),
{
name: 'counter-storage' // the localStorage key
}
)
)
// src/App.tsx
import { useCounterStore } from './stores/counterStore'

function App() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)

return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Persistent counter</h1>
<p className="text-4xl font-bold mb-4">{count}</p>
<div className="space-x-2">
<button
onClick={decrement}
className="px-4 py-2 bg-red-500 text-white rounded"
>
-1
</button>
<button
onClick={increment}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
+1
</button>
<button
onClick={reset}
className="px-4 py-2 bg-gray-500 text-white rounded"
>
Reset
</button>
</div>
<p className="mt-4 text-gray-500">
The value is retained even after reloading the page
</p>
</div>
)
}

export default App

The persist middleware automatically syncs with localStorage. Checking "Application" → "Local Storage" in the browser's DevTools, you can see that data is saved under the key counter-storage. Even after reloading the page, you can resume from the previous value.

The immer middleware

To write updates of nested objects concisely, you can use the immer middleware.

npm install immer
import { immer } from 'zustand/middleware/immer'

type StoreType = {
user: {
profile: {
name: string
email: string
}
}
setUserName: (name: string) => void
}

const useStore = create<StoreType>()(
immer((set) => ({
user: {
profile: {
name: '',
email: ''
}
},
// Without immer: the spread syntax nesting gets deep
// setUser: (name) => set((state) => ({
// user: { ...state.user, profile: { ...state.user.profile, name } }
// }))

// With immer: you can write it as if mutating directly
setUserName: (name) => set((state) => {
state.user.profile.name = name
})
}))
)

Because Immer updates immutably internally, the "do not mutate state directly" principle learned in Chapter 7 is still upheld.

get and set

You can also access the state from outside the store.

const useStore = create<Store>((set, get) => ({
count: 0,
doubleCount: () => get().count * 2, // get the current state with get
increment: () => set((state) => ({ count: state.count + 1 }))
}))

// Access from outside a component
const currentCount = useStore.getState().count
useStore.setState({ count: 10 })

Zustand vs Context API

ItemZustandContext API
Re-rendersOptimized with selectorsAll components that read the Context (partial subscription is not possible)
BoilerplateLessMore
DevToolsSupportedNone
PersistenceMiddleware supportImplement it yourself

Summary

  • Zustand is a lightweight, simple state management library
  • Create a store with create and use it as a hook
  • Subscribe to only the state you need with selectors and optimize
  • Persistence and DevTools integration are possible with middleware
  • No complex setup needed; you can start using it right away

In the next chapter, you will learn about data fetching with TanStack Query.