State Management with Zustand: A Modern Approach
Learn how to use Zustand for lightweight, performant state management in React applications without the boilerplate of Redux.
- React
- Zustand
- State Management
- TypeScript
- Frontend
Zustand is a small, fast, and scalable state management solution for React. After using it extensively in production applications like Unique Sports Group, I've found it to be the perfect balance between simplicity and power. It eliminates Redux boilerplate while providing excellent TypeScript support and performance.
Why Zustand?
Compared to Redux, Zustand offers:
- Less boilerplate: No actions, reducers, or action creators
- Smaller bundle: ~1KB vs Redux's ~10KB+
- Better TypeScript support: Simpler types, less complexity
- No Provider needed: Works without wrapping your app
- Better performance: Only re-renders components using specific state
Basic Setup
Install Zustand:
npm install zustand
Create a simple store:
import { create } from 'zustand';
interface BearState {
bears: number;
increase: (by: number) => void;
reset: () => void;
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
reset: () => set({ bears: 0 }),
}));
// Use in component
function BearCounter() {
const bears = useBearStore((state) => state.bears);
const increase = useBearStore((state) => state.increase);
return (
<div>
<h1>{bears} bears around here...</h1>
<button onClick={() => increase(1)}>Add bear</button>
</div>
);
}
Selective Subscriptions
One of Zustand's best features is selective subscriptions. Components only re-render when the specific state they use changes:
// This component only re-renders when 'bears' changes
function BearCounter() {
const bears = useBearStore((state) => state.bears);
return <div>{bears}</div>;
}
// This component only re-renders when 'fish' changes
function FishCounter() {
const fish = useBearStore((state) => state.fish);
return <div>{fish}</div>;
}
Complex State Management
Async Actions
Handle async operations easily:
interface User {
id: string;
name: string;
}
interface UserState {
users: User[];
loading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
}
const useUserStore = create<UserState>((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/users');
const users: User[] = await response.json();
set({ users, loading: false });
} catch (error) {
set({ error: (error as Error).message, loading: false });
}
},
}));
Computed Values
Derive computed values using selectors:
interface Todo {
id: number;
text: string;
done: boolean;
}
interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
}
const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: Date.now(), text, done: false }]
})),
}));
// Computed selector
function TodoStats() {
const totalTodos = useTodoStore((state) => state.todos.length);
const completedTodos = useTodoStore((state) =>
state.todos.filter(todo => todo.done).length
);
return (
<div>
<p>Total: {totalTodos}</p>
<p>Completed: {completedTodos}</p>
</div>
);
}
Middleware
Zustand supports middleware for common patterns:
Persist Middleware
Persist state to localStorage:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
name: string;
}
interface AuthState {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}),
{
name: 'auth-storage', // localStorage key
}
)
);
Immer Middleware
Use Immer for immutable updates:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface Todo {
id: number;
text: string;
done: boolean;
}
interface TodoState {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
}
const useTodoStore = create<TodoState>()(
immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
state.todos.push({ id: Date.now(), text, done: false });
}),
toggleTodo: (id) => set((state) => {
const todo = state.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}),
}))
);
DevTools Middleware
Connect to Redux DevTools:
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
interface StoreState {
count: number;
increment: () => void;
}
const useStore = create<StoreState>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: 'MyStore' }
)
);
Splitting Stores
For larger applications, split stores by domain:
// stores/authStore.ts
interface User {
id: string;
name: string;
}
interface AuthState {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// stores/todoStore.ts
interface Todo {
id: number;
text: string;
}
interface TodoState {
todos: Todo[];
addTodo: (todo: Todo) => void;
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
addTodo: (todo) => set((state) => ({ todos: [...state.todos, todo] })),
}));
Testing Zustand Stores
Testing Zustand stores is straightforward:
import { renderHook, act } from '@testing-library/react';
import { useBearStore } from './bearStore';
test('increases bears', () => {
const { result } = renderHook(() => useBearStore());
act(() => {
result.current.increase(1);
});
expect(result.current.bears).toBe(1);
});
Best Practices
- Use selectors: Only subscribe to the state you need
- Split stores: Organize by domain, not by component
- Type everything: Leverage TypeScript for type safety
- Use middleware: Persist, Immer, and DevTools when needed
- Keep stores focused: Each store should have a single responsibility
- Avoid over-engineering: Zustand is simple, keep it that way
Real-World Example
Here's a complete example from a production application:
interface Player {
id: string;
name: string;
}
interface FilterState {
search: string;
position: string;
}
interface PlayerState {
players: Player[];
filters: FilterState;
selectedPlayer: Player | null;
setFilters: (filters: FilterState) => void;
setSelectedPlayer: (player: Player | null) => void;
fetchPlayers: () => Promise<void>;
}
export const usePlayerStore = create<PlayerState>((set, get) => ({
players: [],
filters: { search: '', position: 'all' },
selectedPlayer: null,
setFilters: (filters) => set({ filters }),
setSelectedPlayer: (player) => set({ selectedPlayer: player }),
fetchPlayers: async () => {
const { filters } = get();
const players = await api.getPlayers(filters);
set({ players });
},
}));
// Usage in component
function PlayerList() {
const players = usePlayerStore((state) => state.players);
const fetchPlayers = usePlayerStore((state) => state.fetchPlayers);
useEffect(() => {
fetchPlayers();
}, [fetchPlayers]);
return (
<div>
{players.map(player => (
<PlayerCard key={player.id} player={player} />
))}
</div>
);
}
Performance Benefits
In production applications, Zustand has provided:
- 40-60% reduction in re-renders compared to Context API
- Smaller bundle size compared to Redux
- Simpler codebase with less boilerplate
- Better TypeScript experience
- Easier testing and debugging
Zustand strikes the perfect balance between simplicity and power. It's become my go-to state management solution for React applications, especially when you need something more than local state but less than Redux.
Keep reading
React 19: What's New and Why It Matters
Explore the groundbreaking features in React 19, including Actions, useOptimistic, and the new compiler that transforms how we build React applications.
Performance Optimization in React: A Practical Guide
Learn practical techniques to optimize React applications, from code splitting to memoization strategies and rendering optimizations.
Essential TypeScript Features Every Frontend Developer Should Master
The most impactful TypeScript features every React, Angular and Vue developer should master, for better code quality and developer experience.