Zustand is a minimal, unopinionated state management library for React that uses a hook-based API with no providers or boilerplate. Create a store with create(), consume it with a hook, and enjoy automatic re-render optimization through selectors. It supports middleware like persist, devtools, and immer out of the box, works great with TypeScript, and handles SSR in Next.js gracefully. At under 1KB gzipped, Zustand is the go-to choice when Redux feels too heavy and Context re-renders too much.
What Is Zustand and Why Use It?
Zustand (German for "state") is a small, fast, and scalable state management library for React. Created by the team behind Jotai and React Spring, Zustand provides a simple hook-based API that eliminates the need for providers, reducers, or action creators. It stores state outside of React, which means updates only trigger re-renders in components that actually use the changed data.
npm install zustand
# or
yarn add zustand
# or
pnpm add zustandZustand vs Redux vs Jotai vs Recoil vs Context
Each state management solution has trade-offs. Here is how Zustand compares to the alternatives.
| Feature | Zustand | Redux Toolkit | Jotai | Recoil | React Context |
|---|---|---|---|---|---|
| Boilerplate | Minimal | Moderate | Minimal | Medium | Low |
| Provider needed | No | Yes | Yes | Yes | Yes |
| Bundle size | ~1KB | ~11KB | ~3KB | ~14KB | 0KB (built-in) |
| DevTools | Middleware | Built-in | Third party | Extension | None |
| TypeScript | Excellent | Good | Excellent | Good | Excellent |
| SSR support | Native | Needs setup | Native | Limited | Native |
Creating Stores with create()
A Zustand store is created by calling create() with a function that receives set and get. The returned value is a React hook that components use to access the store.
Basic Store
The simplest store holds state and actions together. The set function merges partial state by default, similar to React setState.
import { create } from 'zustand';
// Create a counter store
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Use in a component β no Provider needed!
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
const decrement = useCounterStore((state) => state.decrement);
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}Accessing State Outside React
Zustand stores can be accessed outside of React components. The hook itself has getState() and setState() methods for imperative access.
// Access state outside React
const currentCount = useCounterStore.getState().count;
// Update state imperatively
useCounterStore.setState({ count: 10 });
// Subscribe to all changes
const unsub = useCounterStore.subscribe((state) => {
console.log('Count changed:', state.count);
});
// Unsubscribe when done
unsub();Selectors and Preventing Re-renders
By default, calling useStore() subscribes to the entire store and re-renders on every change. Use selectors to pick only the state slices your component needs.
Basic Selectors
Pass a selector function to the hook to extract specific state. The component only re-renders when the selected value changes (using Object.is comparison).
const useTodoStore = create((set) => ({
todos: [],
filter: 'all',
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: Date.now(), text, done: false }],
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
),
})),
setFilter: (filter) => set({ filter }),
}));
// Only re-renders when todos array changes
function TodoList() {
const todos = useTodoStore((state) => state.todos);
return todos.map((todo) => <div key={todo.id}>{todo.text}</div>);
}
// Only re-renders when filter changes
function FilterBar() {
const filter = useTodoStore((state) => state.filter);
const setFilter = useTodoStore((state) => state.setFilter);
return <select value={filter} onChange={(e) => setFilter(e.target.value)} />;
}Shallow Equality for Object Selectors
When selecting multiple values, use shallow from zustand/shallow to compare by shallow equality instead of referential equality. This prevents unnecessary re-renders when the object shape is the same.
import { useShallow } from 'zustand/shallow';
// BAD: creates a new object every render -> infinite re-renders
// const { name, email } = useUserStore((s) => ({ name: s.name, email: s.email }));
// GOOD: useShallow compares each property individually
function UserProfile() {
const { name, email } = useUserStore(
useShallow((state) => ({ name: state.name, email: state.email }))
);
return <div>{name} ({email})</div>;
}
// Also works with arrays
function UserActions() {
const [updateName, updateEmail] = useUserStore(
useShallow((s) => [s.updateName, s.updateEmail])
);
// ...
}Actions and Async Actions
Actions in Zustand are just functions inside the store that call set(). There is no dispatch, no action types, no reducers. Async actions are equally straightforward.
Synchronous Actions
Define actions alongside state. The set function accepts a partial state object or an updater function that receives the current state.
const useCartStore = create((set, get) => ({
items: [],
totalPrice: 0,
addItem: (product) => set((state) => {
const existing = state.items.find((i) => i.id === product.id);
if (existing) {
return {
items: state.items.map((i) =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { items: [...state.items, { ...product, qty: 1 }] };
}),
removeItem: (id) => set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
// Use get() to read current state inside an action
calculateTotal: () => {
const { items } = get();
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
set({ totalPrice: total });
},
}));Asynchronous Actions
Async actions are regular async functions. Call set() when the data is ready. You can track loading and error states inside the store.
const usePostStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null });
try {
const res = await fetch('/api/posts');
const posts = await res.json();
set({ posts, loading: false });
} catch (err) {
set({ error: err.message, loading: false });
}
},
createPost: async (data) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const newPost = await res.json();
set((state) => ({ posts: [newPost, ...state.posts] }));
},
}));Middleware: persist, devtools, immer, subscribeWithSelector
Zustand supports middleware that wraps the store creator to add functionality. Middleware composes by nesting.
persist β Automatic Local Storage
The persist middleware saves state to localStorage (or any storage) and rehydrates it on page load. You can configure which parts of the state to persist.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
fontSize: 16,
language: 'en',
setTheme: (theme) => set({ theme }),
setFontSize: (fontSize) => set({ fontSize }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // localStorage key
storage: createJSONStorage(() => localStorage),
// Only persist these fields
partialize: (state) => ({
theme: state.theme,
fontSize: state.fontSize,
language: state.language,
}),
}
)
);devtools β Redux DevTools Integration
The devtools middleware connects your store to Redux DevTools for time-travel debugging and action inspection.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set(
(state) => ({ count: state.count + 1 }),
false, // replace: false (merge)
'increment' // action name in DevTools
),
}),
{ name: 'MyAppStore' } // DevTools instance name
)
);immer β Immutable Updates Made Easy
The immer middleware lets you write mutable-looking code that produces immutable updates. This is especially helpful for deeply nested state.
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useNestedStore = create(
immer((set) => ({
user: {
profile: {
name: 'Alice',
address: { city: 'NYC', zip: '10001' },
},
settings: { notifications: true },
},
// Without immer you would need:
// set((s) => ({ user: { ...s.user, profile: { ...s.user.profile,
// address: { ...s.user.profile.address, city: newCity } } } }))
// With immer, just mutate the draft:
updateCity: (city) => set((state) => {
state.user.profile.address.city = city;
}),
toggleNotifications: () => set((state) => {
state.user.settings.notifications =
!state.user.settings.notifications;
}),
}))
);
// Combine middleware: immer + devtools + persist
const useAppStore = create(
devtools(
persist(
immer((set) => ({
// your state and actions
})),
{ name: 'app-storage' }
),
{ name: 'AppStore' }
)
);subscribeWithSelector β Fine-Grained Subscriptions
The subscribeWithSelector middleware enables subscribing to specific state slices outside of React. This is useful for side effects, logging, or syncing with external systems.
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
const useStore = create(
subscribeWithSelector((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}))
);
// Subscribe to a specific slice
const unsub = useStore.subscribe(
(state) => state.count, // selector
(count, prevCount) => { // listener
console.log('Count:', prevCount, '->', count);
if (count >= 10) {
console.log('Reached 10!');
}
},
{ fireImmediately: true } // options
);TypeScript Integration and Typed Stores
Zustand has first-class TypeScript support. Define an interface for your state and pass it as a generic to create().
Fully Typed Store
Define a State interface that includes both state and actions. Pass it as a generic parameter to create for complete type safety.
interface AuthState {
user: { id: string; name: string; email: string } | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
updateProfile: (data: Partial<{ name: string; email: string }>) => void;
}
const useAuthStore = create<AuthState>()((set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (email, password) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const { user, token } = await res.json();
set({ user, token, isAuthenticated: true });
},
logout: () => set({
user: null, token: null, isAuthenticated: false,
}),
updateProfile: (data) => set((state) => ({
user: state.user ? { ...state.user, ...data } : null,
})),
}));Typed Selectors
Selectors automatically infer return types. You can also type selector functions explicitly for complex transformations.
// Type is inferred automatically
function NavBar() {
// userName: string | undefined (inferred)
const userName = useAuthStore((s) => s.user?.name);
// isAuth: boolean (inferred)
const isAuth = useAuthStore((s) => s.isAuthenticated);
return isAuth ? <span>Hello, {userName}</span> : <LoginButton />;
}
// Explicit selector type for complex derivations
const selectUserDisplay = (state: AuthState): string => {
if (!state.user) return 'Guest';
return state.user.name + ' (' + state.user.email + ')';
};
function UserBadge() {
const display = useAuthStore(selectUserDisplay);
return <span>{display}</span>;
}Slices Pattern for Large Stores
For large applications, split your store into slices. Each slice manages a subset of state and can access other slices through the full store.
Creating and Combining Slices
Each slice is a function that receives set and get and returns a partial state object. Combine slices into a single create() call.
// types.ts
interface UserSlice {
user: { name: string } | null;
setUser: (user: { name: string }) => void;
}
interface CartSlice {
items: Array<{ id: string; name: string; qty: number }>;
addItem: (item: { id: string; name: string }) => void;
clearCart: () => void;
}
type AppState = UserSlice & CartSlice;
// slices/userSlice.ts
const createUserSlice = (set: any): UserSlice => ({
user: null,
setUser: (user) => set({ user }),
});
// slices/cartSlice.ts
const createCartSlice = (set: any, get: any): CartSlice => ({
items: [],
addItem: (item) => set((state: AppState) => ({
items: [...state.items, { ...item, qty: 1 }],
})),
// Access user slice from cart slice via get()
clearCart: () => {
const user = get().user;
console.log('Clearing cart for:', user?.name);
set({ items: [] });
},
});
// store.ts β combine slices
import { create } from 'zustand';
const useAppStore = create<AppState>()((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
}));Using Zustand with Next.js (SSR Hydration)
Zustand works with Next.js but requires special handling for server-side rendering. The key challenge is avoiding shared state between requests on the server.
SSR-Safe Store Setup
Create a store factory that produces a new store per request on the server. Use a React context provider to pass the store instance to components.
// store.ts
import { createStore } from 'zustand';
interface AppState {
count: number;
increment: () => void;
}
// Factory: creates a NEW store instance each call
export const createAppStore = (initialCount = 0) => {
return createStore<AppState>()((set) => ({
count: initialCount,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
};
export type AppStore = ReturnType<typeof createAppStore>;
// provider.tsx
'use client';
import { createContext, useContext, useRef } from 'react';
import { useStore } from 'zustand';
const StoreContext = createContext<AppStore | null>(null);
export function StoreProvider({
children,
initialCount,
}: {
children: React.ReactNode;
initialCount: number;
}) {
const storeRef = useRef<AppStore>(null);
if (!storeRef.current) {
storeRef.current = createAppStore(initialCount);
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
}
// Custom hook for accessing the store
export function useAppStore<T>(selector: (state: AppState) => T): T {
const store = useContext(StoreContext);
if (!store) throw new Error('Missing StoreProvider');
return useStore(store, selector);
}Testing Zustand Stores
Zustand stores are plain JavaScript objects, making them easy to test without React rendering. You can test stores in isolation or with components.
Unit Testing a Store
Test store logic by calling actions and checking state directly. No React rendering needed.
// counterStore.test.ts
import { useCounterStore } from './counterStore';
describe('counterStore', () => {
// Reset store before each test
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});
it('increments count', () => {
useCounterStore.getState().increment();
expect(useCounterStore.getState().count).toBe(1);
});
it('decrements count', () => {
useCounterStore.setState({ count: 5 });
useCounterStore.getState().decrement();
expect(useCounterStore.getState().count).toBe(4);
});
it('resets count', () => {
useCounterStore.setState({ count: 99 });
useCounterStore.getState().reset();
expect(useCounterStore.getState().count).toBe(0);
});
});Testing Components with Stores
When testing components that use Zustand stores, reset the store before each test to avoid state leaking between tests.
// Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { useCounterStore } from './counterStore';
import { Counter } from './Counter';
// Reset store before each test
const initialState = useCounterStore.getState();
beforeEach(() => {
useCounterStore.setState(initialState, true);
});
it('displays the count and increments on click', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
fireEvent.click(screen.getByText('+'));
expect(screen.getByText('1')).toBeInTheDocument();
});Zustand vs React Context
React Context is built-in and simple, but it has a critical performance problem: any update to the context value re-renders every consumer. Zustand solves this by storing state outside React and using selectors for granular subscriptions.
The React Context Problem
With Context, changing a single value re-renders every component that consumes the context, even if that component does not use the changed value. Zustand components only re-render when their selected slice of state changes.
// PROBLEM: React Context re-renders all consumers
const AppContext = React.createContext(null);
function App() {
const [state, setState] = useState({
theme: 'dark',
user: 'Alice',
count: 0,
});
return (
<AppContext.Provider value={{ state, setState }}>
{/* ALL children re-render when ANY value changes */}
<ThemeDisplay /> {/* re-renders on count change */}
<UserDisplay /> {/* re-renders on count change */}
<Counter /> {/* re-renders on theme change */}
</AppContext.Provider>
);
}
// SOLUTION: Zustand with selectors
const useAppStore = create((set) => ({
theme: 'dark',
user: 'Alice',
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// Only re-renders when theme changes
function ThemeDisplay() {
const theme = useAppStore((s) => s.theme);
return <div>Theme: {theme}</div>;
}
// Only re-renders when count changes
function Counter() {
const count = useAppStore((s) => s.count);
const increment = useAppStore((s) => s.increment);
return <button onClick={increment}>{count}</button>;
}Performance Optimization
Zustand is fast by default, but there are patterns to squeeze out maximum performance in large applications.
Atomic Selectors
Select the smallest unit of state possible. Avoid selecting entire objects when you only need one property.
// BAD: selects the entire user object
// Re-renders when ANY user property changes
function Avatar() {
const user = useStore((s) => s.user);
return <img src={user.avatar} />;
}
// GOOD: selects only what is needed
// Only re-renders when avatar URL changes
function Avatar() {
const avatar = useStore((s) => s.user.avatar);
return <img src={avatar} />;
}Transient Updates (No Re-render)
Use subscribe() for updates that should not trigger re-renders, like animations or frequent data streams.
// Transient updates: update DOM directly without re-rendering
import { useEffect, useRef } from 'react';
const useMouseStore = create((set) => ({
x: 0,
y: 0,
setPosition: (x, y) => set({ x, y }),
}));
// This component NEVER re-renders from mouse moves
function Cursor() {
const ref = useRef(null);
useEffect(() => {
// Subscribe to store changes and update DOM directly
const unsub = useMouseStore.subscribe((state) => {
if (ref.current) {
ref.current.style.transform =
'translate(' + state.x + 'px, ' + state.y + 'px)';
}
});
return unsub;
}, []);
return <div ref={ref} style={{ width: 20, height: 20 }} />;
}Best Practices and Common Patterns
- Keep stores small and focused on a single domain β prefer multiple stores over one mega store
- Always use selectors to prevent unnecessary re-renders β never call useStore() without arguments
- Colocate actions with the state they modify inside the store
- Use the immer middleware for deeply nested state updates
- Reset stores in test setup to prevent state leaking between tests
- Use persist middleware with partialize to only save essential data to storage
- Prefer useShallow from zustand/shallow when selecting multiple values
- For SSR, create store instances per request and pass them via context
Frequently Asked Questions
Is Zustand better than Redux?
Zustand is not universally better but is a strong choice for most applications. Redux Toolkit remains a good option for very large teams that benefit from strict conventions, middleware ecosystem, and established patterns. Zustand excels when you want minimal boilerplate, smaller bundle size, and a simpler mental model. For new projects in 2026, Zustand is often the recommended starting point.
Does Zustand need a Provider component?
No. Zustand stores exist outside of the React tree by default, so no Provider is needed. This is one of its biggest advantages β you can use the store hook anywhere without wrapping your app. The only exception is SSR scenarios where you need per-request store instances, in which case you create a custom provider.
Can Zustand replace React Context for global state?
Yes, and it is recommended for most cases. React Context re-renders all consumers when any value changes, while Zustand uses selectors to re-render only the components that use the changed data. For theme toggles or locale, Context is fine. For frequently changing data (form state, real-time data, UI state), Zustand is significantly more performant.
How does Zustand handle TypeScript?
Zustand has excellent TypeScript support. You define a State interface and pass it as a generic to create(). All selectors, actions, and middleware are fully typed. The create() function infers types from the initial state when no generic is provided, though explicit typing is recommended for complex stores.
Can Zustand persist state to localStorage?
Yes, using the built-in persist middleware. It automatically saves state to localStorage and rehydrates it on page load. You can customize the storage engine (sessionStorage, AsyncStorage for React Native, or any custom storage), choose which state slices to persist with partialize, and configure migration functions for schema changes.
How do I use Zustand with Next.js SSR?
Create a store factory function that returns a new store instance. On the server, each request gets its own store to prevent shared state between users. Pass the store via a React context provider and use a custom hook to access it. Zustand provides a createStore function (without the React hook wrapper) specifically for this pattern.
How does Zustand compare to Jotai?
Both are created by the same team (Poimandres). Zustand uses a top-down approach with stores, while Jotai uses a bottom-up atomic approach similar to Recoil. Choose Zustand when you have well-defined state shapes and want module-level access. Choose Jotai when you prefer atomic, composable pieces of state that are defined close to their consumers.
Is Zustand production-ready?
Absolutely. Zustand is used in production by thousands of companies and has over 50,000 GitHub stars. It is actively maintained, well-documented, and has a stable API. Major companies use it for everything from small dashboards to large-scale applications.
- Zustand provides the simplest API for React state management β create a store, use a hook, done
- Selectors are the key to performance β always select only what you need
- Built-in middleware (persist, devtools, immer) covers the most common needs without extra packages
- TypeScript support is first-class with full type inference for stores, selectors, and middleware
- At under 1KB gzipped, Zustand adds virtually no overhead to your bundle
- The slices pattern scales to large applications while keeping code organized