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
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
| Library | Characteristic |
|---|---|
| useState | For local state |
| useContext | Lightweight global state |
| Redux | For large apps, complex setup |
| Zustand | Lightweight, simple API |
| Jotai | Atomic state management |
Zustand is the best option when you need global state management while avoiding Redux's complexity.
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
This book uses Zustand v5 (5.0.14 at the time of writing). If your major version differs, the API may have changed.
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.
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
- Define the
countstate and update functions with thecreatefunction - Update the state with the
set((state) => ({ count: state.count + 1 }))pattern - 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.
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
- Subscribe to count only with
useCounterStore((state) => state.count) - Action functions do not change, so they do not cause re-renders
- Add
console.logto 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
- Wrap
createwith thepersistmiddleware - Specify the localStorage key with the
nameoption - 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
| Item | Zustand | Context API |
|---|---|---|
| Re-renders | Optimized with selectors | All components that read the Context (partial subscription is not possible) |
| Boilerplate | Less | More |
| DevTools | Supported | None |
| Persistence | Middleware support | Implement it yourself |
Summary
- Zustand is a lightweight, simple state management library
- Create a store with
createand 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.