Zustand State Management: A Comprehensive Guide for React Developers
Learn Zustand, a powerful state management library for React, to build scalable applications. This guide covers setup, performance optimization with selectors and middleware like Immer and Persist, and best practices for managing complex state efficiently.
Introduction

Zustand is a small, fast, and scalable state management solution that simplifies global state management in React applications. It allows developers to build applications ranging from simple counters to complex in-browser card games with high performance and maintainability.
Configuration Checklist
| Element | Version / Link |
|---|---|
| Language / Runtime | TypeScript, JavaScript / Node.js |
| Main library | Zustand (npm install zustand) |
| Required APIs | React Hooks (useState, useContext) |
| Keys / credentials needed | None (for basic setup) |
Step-by-Step Guide
Step 1 — Setting Up the Initial React Context Counter
To understand Zustand's benefits, we first set up a basic counter application using React's Context API. This demonstrates the common re-rendering issues that Zustand aims to solve.
src/state/CounterContext.tsx
import { createContext, useState, useContext, ReactNode } from 'react';
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
// Create the context with a default null value, asserting its type
const CounterContext = createContext<CounterState | null>(null);
// CounterProvider component to wrap the application and provide state
export function CounterProvider({ children }: { children: ReactNode }) {
const [count, setCount] = useState(0);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(0);
// Provide the count and functions to update it
return (
<CounterContext.Provider value={{ count, increment, decrement, reset }}>
{children}
</CounterContext.Provider>
);
}
// Custom hook to consume the counter context
export function useCounter() {
const value = useContext(CounterContext);
if (value === null) {
throw new Error('useCounter must be wrapped by CounterProvider');
}
return value;
}
src/components/Counter.tsx
import { useRef } from 'react';
import { useFlash } from '../hooks/flash'; // Custom hook to visually indicate re-renders
import { CounterProvider, useCounter } from '../state/CounterContext';
// Main Counter component that wraps the display and controls with the provider
export function Counter() {
return (
<CounterProvider>
<CountDisplay />
<CountControls />
</CounterProvider>
);
}
// Component to display the current count and re-render count
function CountDisplay() {
const { count } = useCounter(); // Consumes the count from context
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef); // Visually flashes on re-render
return (
<div className="card" ref={cardRef}>
<p className="count-display">{count}</p>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
// Component with controls to modify the count
function CountControls() {
const { increment, decrement, reset } = useCounter(); // Consumes functions from context
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef); // Visually flashes on re-render
// Function to increment count outside React component tree (for demonstration)
function incrementOutsideReact(incrementFn: () => void) {
console.log('Outside of React');
incrementFn();
}
return (
<div className="card" ref={cardRef}>
<div className="button-group">
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+1</button>
<button onClick={() => incrementOutsideReact(increment)}>+1 Outside</button>
</div>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
Step 2 — Installing Zustand and Creating a Basic Store
Zustand is installed via npm. A store is created using the create function, which returns a custom hook. This hook provides access to the store's state and a set function to update it.
Install Command:
npm install zustand
src/state/useCounterStore.ts
import { create } from "zustand";
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
// Create the Zustand store, typed with CounterState
export const useCounterStore = create<CounterState>((set) => ({
count: 0, // Initial state
// Actions to update the state using the 'set' function
increment: () => set((state) => ({ count: state.count + 1 })), // Zustand automatically merges partial state updates
decrement: () => set((state) => ({ count: state.count - 1 })), // Decrement count
reset: () => set({ count: 0 }), // Reset count to 0
}));
Step 3 — Replacing React Context with Zustand in Components
We replace the CounterProvider and useCounter hook with the useCounterStore hook. Initially, both components will still re-render excessively because the entire store object is returned on each call.
src/components/Counter.tsx
import { useRef } from 'react';
import { useFlash } from '../hooks/flash';
import { useCounterStore } from '../state/useCounterStore'; // Import the Zustand store hook
// Main Counter component, now without CounterProvider
export function Counter() {
return (
<>
<CountDisplay />
<CountControls />
</>
);
}
// Component to display the current count and re-render count
function CountDisplay() {
// Directly use the Zustand store hook
const { count } = useCounterStore(); // This will cause re-renders if any part of the store changes
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef);
return (
<div className="card" ref={cardRef}>
<p className="count-display">{count}</p>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
// Component with controls to modify the count
function CountControls() {
// Directly use the Zustand store hook
const { increment, decrement, reset } = useCounterStore(); // This will also cause re-renders
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef);
// Function to increment count outside React component tree
function incrementOutsideReact(incrementFn: () => void) {
console.log('Outside of React');
incrementFn();
}
return (
<div className="card" ref={cardRef}>
<div className="button-group">
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+1</button>
<button onClick={() => incrementOutsideReact(increment)}>+1 Outside</button>
</div>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
Step 4 — Optimizing Re-renders with Selectors and useShallow
To prevent unnecessary re-renders, Zustand allows you to select only the specific parts of the state your component needs. For multiple selections, useShallow ensures that the component only re-renders if the selected values actually change, not just their object reference.
Install Command (for shallow):
npm install zustand
src/components/Counter.tsx
import { useRef } from 'react';
import { useFlash } from '../hooks/flash';
import { useCounterStore } from '../state/useCounterStore';
import { shallow } from 'zustand/shallow'; // Import shallow for optimized object comparison
export function Counter() {
return (
<>
<CountDisplay />
<CountControls />
</>
);
}
function CountDisplay() {
// Select only the 'count' value, so this component only re-renders when 'count' changes
const count = useCounterStore((state) => state.count);
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef);
return (
<div className="card" ref={cardRef}>
<p className="count-display">{count}</p>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
function CountControls() {
// Select multiple functions and use 'shallow' to prevent re-renders if functions themselves don't change
const { increment, decrement, reset } = useCounterStore(
(state) => ({
increment: state.increment,
decrement: state.decrement,
reset: state.reset,
}),
shallow // Use shallow comparison for the returned object
);
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef);
function incrementOutsideReact(incrementFn: () => void) {
console.log('Outside of React');
incrementFn();
}
return (
<div className="card" ref={cardRef}>
<div className="button-group">
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+1</button>
<button onClick={() => incrementOutsideReact(increment)}>+1 Outside</button>
</div>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
Step 5 — Accessing State Outside React Components
Zustand stores can be accessed and updated outside of React components, which is useful for non-React logic or integrating with other frameworks. The store itself provides getState() and setState() methods.
src/state/useCounterStore.ts
import { create } from "zustand";
type CounterState = {
count: number;
};
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
}));
// Export actions as standalone functions for access outside React
export const increment = () => useCounterStore.setState((state) => ({ count: state.count + 1 }));
export const decrement = () => useCounterStore.setState((state) => ({ count: state.count - 1 }));
export const reset = () => useCounterStore.setState({ count: 0 });
src/components/Counter.tsx
import { useRef } from 'react';
import { useFlash } from '../hooks/flash';
import { useCounterStore, increment, decrement, reset } from '../state/useCounterStore'; // Import actions directly
import { shallow } from 'zustand/shallow';
export function Counter() {
return (
<>
<CountDisplay />
<CountControls />
</>
);
}
function CountDisplay() {
const count = useCounterStore((state) => state.count);
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef);
return (
<div className="card" ref={cardRef}>
<p className="count-display">{count}</p>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
function CountControls() {
// Actions are now imported directly, no need to select them from the store hook
const cardRef = useRef<HTMLDivElement>(null);
const renders = useFlash(cardRef);
// Function to increment count outside React component tree
function incrementOutsideReact() {
console.log('Outside of React');
// Directly call the exported increment function
increment();
}
return (
<div className="card" ref={cardRef}>
<div className="button-group">
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+1</button>
<button onClick={incrementOutsideReact}>+1 Outside</button>
</div>
<p className="render-count">Renders: {renders}</p>
</div>
);
}
Step 6 — Persisting State with persist Middleware
Zustand offers middleware to extend store functionality. The persist middleware allows you to save and load your store's state from storage (like local storage) automatically.
Install Command:
npm install zustand
src/state/useCounterStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware"; // Import persist middleware
type CounterState = {
count: number;
};
// Wrap the store creation with persist middleware
export const useCounterStore = create<CounterState>()(
persist(
(set) => ({
count: 0,
}),
{
name: "count", // Name for the storage key (e.g., in local storage)
}
)
);
export const increment = () => useCounterStore.setState((state) => ({ count: state.count + 1 }));
export const decrement = () => useCounterStore.setState((state) => ({ count: state.count - 1 }));
export const reset = () => useCounterStore.setState({ count: 0 });
Step 7 — Managing Nested State with Immer Middleware
For deeply nested state, manual immutable updates can become verbose. The immer middleware simplifies this by allowing you to write mutable-looking code that Immer then translates into immutable updates.
Install Command:
npm install immer
src/state/useCounterStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer"; // Import immer middleware
type User = {
name: string;
address: {
street: string;
zipcode: string;
};
};
type CounterState = {
count: number;
user: User; // Add a nested user object to the state
};
export const useCounterStore = create<CounterState>()(
immer( // Wrap with immer middleware
persist(
(set) => ({
count: 0,
user: {
name: "Kyle",
address: {
street: "Main St",
zipcode: "23423",
},
}, // Initial nested user state
}),
{
name: "count",
}
)
)
);
export const increment = () => useCounterStore.setState((state) => { state.count++; }); // Direct mutation with immer
export const decrement = () => useCounterStore.setState((state) => { state.count--; }); // Direct mutation with immer
export const reset = () => useCounterStore.setState((state) => { state.count = 0; }); // Direct mutation with immer
// Function to update a nested property using immer
export const updateStreet = (newStreet: string) =>
useCounterStore.setState((state) => {
state.user.address.street = newStreet; // Direct mutation of nested state is safe with immer
});
Comparison Tables
| Feature / Tool | React Context API | Zustand |
|---|---|---|
| Ease of Setup | Relatively easy for simple cases | Easy, similar to Context but more flexible |
| Performance (Re-renders) | All consuming components re-render on any context change, leading to potential performance issues | Optimized re-renders with selectors; only components consuming changed state re-render |
| Scalability | Can become difficult to scale with large, complex state trees due to re-render issues | Highly scalable, designed for large applications with fine-grained control over re-renders |
| Access Outside React | Requires passing functions/state as props or through custom hooks within the React tree | Direct access to getState() and setState() methods anywhere, independent of React components |
| Nested State Management | Requires manual immutable updates (e.g., spreading objects) which can be verbose | Simplified with immer middleware, allowing direct mutation of draft state |
⚠️ Common Mistakes & Pitfalls
- Excessive Re-renders with React Context: When any value in a React Context changes, all components consuming that context will re-render, even if they don't use the specific changed value. This can lead to performance bottlenecks in larger applications.
- Fix: Use a state management library like Zustand with selectors to ensure components only re-render when their specific dependencies change.
- Excessive Re-renders with Zustand (without selectors): If you call
useCounterStore()without a selector (e.g.,const { count } = useCounterStore();), your component will receive the entire store object. If any part of the store changes, React will see a new object reference and re-render the component.- Fix: Always use selectors to extract only the necessary state:
const count = useCounterStore(state => state.count);.
- Fix: Always use selectors to extract only the necessary state:
- Excessive Re-renders with Zustand (object selectors without
shallow): When selecting multiple values or an object from the store (e.g.,const { a, b } = useCounterStore(state => ({ a: state.a, b: state.b }));), a new object reference is created on each render. React's default strict equality check will see this as a change, causing unnecessary re-renders.- Fix: Pass
shallowas the second argument to theuseStorehook for object comparisons:const { a, b } = useCounterStore(state => ({ a: state.a, b: state.b }), shallow);.
- Fix: Pass
- Complex Immutable Updates for Nested State: Manually updating deeply nested state objects requires careful spreading of properties (
{ ...state, user: { ...state.user, address: { ...state.user.address, street: 'New St' } } }), which is verbose and error-prone.- Fix: Use the
immermiddleware with Zustand. It allows you to write mutable-looking code (e.g.,state.user.address.street = 'New St';) that is automatically converted into immutable updates, simplifying complex state logic.
- Fix: Use the
Glossary
Zustand: A lightweight, flexible, and performant state management library for React applications, built on hooks.
React Context API: A feature in React that enables components to share state without explicitly passing props down through every level of the component tree.
Middleware (Zustand): Functions that wrap the store's set method, allowing for enhanced functionality such as logging, persistence, or immutable state updates.
Selector (Zustand): A function used with the useStore hook to extract specific pieces of state, ensuring components only re-render when those particular pieces of state change.
Shallow Equality: A comparison method that checks if two objects have the same keys and if the values of those keys are strictly equal, but does not recursively compare nested objects.
Immer: A JavaScript library that simplifies working with immutable data structures by letting you write code as if you were mutating data directly, while it handles the creation of new immutable states behind the scenes.
Persist Middleware: A Zustand middleware that automatically saves and loads the store's state to and from a chosen storage mechanism, such as local storage.
Key Takeaways
- Zustand offers a simpler and more performant alternative to React Context for global state management.
- By default, React Context re-renders all consumers when any part of the context changes, leading to performance issues.
- Zustand's
createfunction returns a custom hook that provides access to the store's state and asetfunction for updates. - Use selectors with
useCounterStoreto ensure components only re-render when the specific data they depend on changes. - For selecting multiple values or objects, use
shallowfromzustand/shallowas a comparator to prevent unnecessary re-renders. - Zustand stores can be accessed and updated outside of React components, offering great flexibility for various application architectures.
- The
persistmiddleware (zustand/middleware) allows for easy state persistence to local storage or other custom storage solutions. - The
immermiddleware (zustand/middleware/immer) simplifies managing deeply nested state by allowing direct mutation of a draft state, which is then immutably updated.