The Complete Zustand Guide | Simple State Management, React, TypeScript, Middleware & Production Use
What this post covers
This is a complete guide to implementing simple state management with Zustand. It covers going lightweight without Redux, TypeScript support, middleware, and persist—with practical examples throughout.
From the field: When migrating from Redux to Zustand, we cut boilerplate by about 90% and reduced bundle size by roughly 50%—this post shares that experience.
Introduction: “Redux feels too heavy”
Real-world scenarios
Scenario 1: Too much boilerplate
Redux is complex. Zustand stays simple. Scenario 2: Bundle size
Redux is heavy. Zustand is about 1KB. Scenario 3: TypeScript setup is painful
Redux typing can be intricate. Zustand infers types with less ceremony.
1. What is Zustand?
Core characteristics
Zustand is a small, focused React state management library.
Main advantages:
- Simple API: almost no boilerplate
- Tiny footprint: ~1KB
- TypeScript: strong, ergonomic support
- Middleware: persist, devtools, and more
- Outside React: usable from vanilla JavaScript
2. Basic usage
Installation
npm install zustand
Creating a store
Below is a detailed TypeScript example. Import what you need, define types for your data and actions, and walk through each part to see how it fits together.
// store/useStore.ts
import { create } from 'zustand';
interface Store {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useStore = create<Store>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
Using it in components
// components/Counter.tsx
import { useStore } from '../store/useStore';
export default function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
const decrement = useStore((state) => state.decrement);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
3. Advanced patterns
Async actions
Below is a TypeScript example with async work, error handling, and loading state. Follow each piece to see how the store stays predictable under network calls.
interface UserStore {
users: User[];
loading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
}
export const useUserStore = create<UserStore>((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/users');
const users = await response.json();
set({ users, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
Computed values
This TypeScript snippet shows derived state via a function that reads the current store with get().
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
total: () => {
const { items } = get();
return items.reduce((sum, item) => sum + item.price, 0);
},
}));
4. Middleware
Persist
TypeScript example: import create and persist, type your auth slice, and persist user/session fields to storage under a stable key.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthStore {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}),
{
name: 'auth-storage',
}
)
);
Devtools
import { devtools } from 'zustand/middleware';
export const useStore = create<Store>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: 'CounterStore' }
)
);
Immer
import { immer } from 'zustand/middleware/immer';
export const useStore = create<Store>()(
immer((set) => ({
nested: { deep: { value: 0 } },
updateDeep: (value: number) =>
set((state) => {
state.nested.deep.value = value;
}),
}))
);
5. Slice pattern
// store/slices/userSlice.ts
export const createUserSlice = (set, get) => ({
users: [],
fetchUsers: async () => {
const users = await api.getUsers();
set({ users });
},
});
// store/slices/cartSlice.ts
export const createCartSlice = (set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});
// store/index.ts
import { create } from 'zustand';
import { createUserSlice } from './slices/userSlice';
import { createCartSlice } from './slices/cartSlice';
export const useStore = create((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get),
}));
6. Selector optimization
Anti-pattern
// Subscribe to the entire store (extra re-renders)
const store = useStore();
Preferred pattern
// Subscribe only to the slices you need
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
Shallow comparison
import { shallow } from 'zustand/shallow';
const { count, increment } = useStore(
(state) => ({ count: state.count, increment: state.increment }),
shallow
);
7. Vanilla JS
import { createStore } from 'zustand/vanilla';
const store = createStore<Store>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// Subscribe
const unsubscribe = store.subscribe((state) => {
console.log('Count:', state.count);
});
// Use
store.getState().increment();
console.log(store.getState().count); // 1
// Unsubscribe
unsubscribe();
Connecting to interviews and hiring
Global state and store design come up often in frontend interviews. For how to prepare, see the Technical Interview Preparation Guide. For writing quantified stories on your resume—such as migrating from Redux to Zustand—see the Developer Resume, Screening, and Interview Guide.
Summary and checklist
Key takeaways
- Zustand: straightforward global state
- Small bundle: ~1KB
- TypeScript: strong support
- Middleware: persist, devtools, immer
- Selectors: tune subscriptions for performance
- Vanilla JS: use outside React when needed
Implementation checklist
- Install Zustand
- Create a store
- Wire up components
- Add async actions
- Add middleware
- Optimize selectors
- Apply the slice pattern
Related reading
- React Native complete guide
- Next.js App Router guide
- TypeScript complete guide
Keywords in this post
Zustand, State Management, React, TypeScript, Redux, Frontend, Performance
Frequently asked questions (FAQ)
Q. How does it compare to Redux?
A. Zustand is much simpler and lighter. Redux offers a larger feature set but with more complexity.
Q. How does it compare to the Context API?
A. Zustand tends to perform better and stays easier to use for global state. Context is a good fit for avoiding prop drilling with lighter, more localized data.
Q. Is it production-ready?
A. Yes—many teams use it in production without issue.
Q. Does it support SSR?
A. Yes—you can use it with Next.js and other SSR setups.