- Published on
Zustand for React: Simple State Management Made Easy
- Authors

- Name
- Mehdi Akiki
Zustand for React: Simple State Management Made Easy
If you've ever built a React app and found yourself passing data through many components (also called "prop drilling"), you know it can get messy. That's where state management libraries come in. Today, I want to introduce you to Zustand - a simple, lightweight way to manage your app's state.
Think of Zustand like a shared refrigerator in your house. Everyone in the family can put things in it or take things out, and everyone knows what's available. No need to pass groceries through multiple rooms!
What is Zustand?
Zustand (German for "state") is a small state management library for React. It's:
- Simple - Just a few lines of code to get started
- Lightweight - Only about 1KB in size
- Powerful - Can handle complex state logic
- TypeScript-friendly - Great type support out of the box
Unlike bigger libraries like Redux, Zustand doesn't require you to write tons of boilerplate code or follow strict patterns.
Why Use Zustand?
Before Zustand (The Problem)
// Without state management - passing props everywhere
function App() {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
return (
<div>
<Header user={user} setUser={setUser} />
<ProductList cart={cart} setCart={setCart} />
<Cart cart={cart} setCart={setCart} />
</div>
);
}
function Header({ user, setUser }) {
return <UserProfile user={user} setUser={setUser} />;
}
function UserProfile({ user, setUser }) {
// Finally using the data here!
return <div>Hello {user?.name}</div>;
}
After Zustand (The Solution)
// With Zustand - clean and simple
function App() {
return (
<div>
<Header />
<ProductList />
<Cart />
</div>
);
}
function Header() {
return <UserProfile />;
}
function UserProfile() {
const user = useUserStore((state) => state.user);
return <div>Hello {user?.name}</div>;
}
Getting Started
1. Install Zustand
npm install zustand
2. Create Your First Store
A store is like a container that holds your app's data and functions to change it.
// stores/userStore.js
import { create } from "zustand";
export const useUserStore = create((set) => ({
// Initial state
user: null,
isLoggedIn: false,
// Actions (functions to change state)
login: (userData) =>
set({
user: userData,
isLoggedIn: true,
}),
logout: () =>
set({
user: null,
isLoggedIn: false,
}),
updateProfile: (newData) =>
set((state) => ({
user: { ...state.user, ...newData },
})),
}));
3. Use the Store in Components
// components/LoginButton.js
import { useUserStore } from "../stores/userStore";
function LoginButton() {
const login = useUserStore((state) => state.login);
const isLoggedIn = useUserStore((state) => state.isLoggedIn);
const handleLogin = () => {
// Simulate login
login({
name: "John Doe",
email: "[email protected]",
});
};
return (
<button onClick={handleLogin} disabled={isLoggedIn}>
{isLoggedIn ? "Logged In" : "Login"}
</button>
);
}
Real-World Examples
Shopping Cart Store
// stores/cartStore.js
import { create } from "zustand";
export const useCartStore = create((set, get) => ({
items: [],
total: 0,
addItem: (product) =>
set((state) => {
const existingItem = state.items.find((item) => item.id === product.id);
if (existingItem) {
// Update quantity if item exists
const updatedItems = state.items.map((item) =>
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
);
return {
items: updatedItems,
total: state.total + product.price,
};
} else {
// Add new item
return {
items: [...state.items, { ...product, quantity: 1 }],
total: state.total + product.price,
};
}
}),
removeItem: (productId) =>
set((state) => {
const itemToRemove = state.items.find((item) => item.id === productId);
if (!itemToRemove) return state;
return {
items: state.items.filter((item) => item.id !== productId),
total: state.total - itemToRemove.price * itemToRemove.quantity,
};
}),
clearCart: () => set({ items: [], total: 0 }),
getItemCount: () => {
const { items } = get();
return items.reduce((total, item) => total + item.quantity, 0);
},
}));
Using the Cart Store
// components/CartIcon.js
import { useCartStore } from "../stores/cartStore";
function CartIcon() {
const itemCount = useCartStore((state) => state.getItemCount());
const total = useCartStore((state) => state.total);
return (
<div className="cart-icon">
🛒 {itemCount} items - ${total.toFixed(2)}
</div>
);
}
// components/ProductCard.js
import { useCartStore } from "../stores/cartStore";
function ProductCard({ product }) {
const addItem = useCartStore((state) => state.addItem);
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
);
}
Advanced Patterns
Async Actions
// stores/apiStore.js
import { create } from "zustand";
export const useApiStore = create((set) => ({
data: null,
loading: false,
error: null,
fetchData: async (endpoint) => {
set({ loading: true, error: null });
try {
const response = await fetch(endpoint);
const data = await response.json();
set({ data, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
clearData: () => set({ data: null, error: null }),
}));
Using Async Store
// components/DataDisplay.js
import { useApiStore } from "../stores/apiStore";
function DataDisplay() {
const { data, loading, error, fetchData } = useApiStore();
React.useEffect(() => {
fetchData("/api/users");
}, [fetchData]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!data) return <div>No data</div>;
return (
<div>
{data.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
Selectors for Performance
// Only re-render when specific parts of state change
function UserProfile() {
// This component only re-renders when user data changes
const user = useUserStore((state) => state.user);
// This component only re-renders when login status changes
const isLoggedIn = useUserStore((state) => state.isLoggedIn);
return <div>{isLoggedIn ? <div>Welcome {user?.name}!</div> : <div>Please log in</div>}</div>;
}
Zustand vs Other State Management
Zustand vs useState
- useState: Great for simple component state
- Zustand: Perfect for state shared across multiple components
Zustand vs Context API
- Context: Built into React, good for simple cases
- Zustand: More powerful, better performance with large apps
Zustand vs Redux
- Redux: Powerful but requires lots of boilerplate
- Zustand: Simple and powerful without the complexity
Middleware & Ecosystem
Popular Middleware
// Persist state to localStorage
import { persist } from "zustand/middleware";
export const useUserStore = create(
persist(
(set, get) => ({
user: null,
login: (userData) => set({ user: userData }),
logout: () => set({ user: null }),
}),
{
name: "user-storage", // key in localStorage
}
)
);
// DevTools integration
import { devtools } from "zustand/middleware";
export const useCounterStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: "counter-store",
}
)
);
Zustand Ecosystem
- zustand/middleware: persist, devtools, immer
- zustand/combine: Combine multiple stores
- zustand/context: React Context integration
- zustand/immer: Immutable state updates
Performance Optimization
Selective Subscriptions
// Only re-render when specific state changes
function UserProfile() {
const userName = useUserStore((state) => state.user?.name);
const isLoggedIn = useUserStore((state) => state.isLoggedIn);
// Component only re-renders when userName or isLoggedIn changes
return <div>{isLoggedIn ? userName : "Please login"}</div>;
}
Memoization
// Use useCallback for actions to prevent unnecessary re-renders
const useStore = create((set) => ({
items: [],
addItem: useCallback((item) => {
set((state) => ({ items: [...state.items, item] }));
}, []),
}));
Store Splitting
// Split large stores into focused ones
const useUserStore = create(/* user logic */);
const useCartStore = create(/* cart logic */);
const useNotificationStore = create(/* notification logic */);
Testing Zustand Stores
Testing Actions
import { act, renderHook } from "@testing-library/react";
import { useCounterStore } from "../stores/counterStore";
test("increment increases count", () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Testing Async Actions
test("fetchUser updates state correctly", async () => {
// Mock the API call
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: "John" }),
})
);
const { result } = renderHook(() => useUserStore());
await act(async () => {
await result.current.fetchUser(1);
});
expect(result.current.user).toEqual({ name: "John" });
});
Migration Guide
From useState to Zustand
// Before: Multiple useState hooks
function Component() {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
// ... more state
}
// After: Single store
const useAppStore = create((set) => ({
user: null,
cart: [],
setUser: (user) => set({ user }),
addToCart: (item) => set((state) => ({ cart: [...state.cart, item] })),
}));
From Redux to Zustand
// Redux reducer
const counterReducer = (state = 0, action) => {
switch (action.type) {
case "INCREMENT":
return state + 1;
default:
return state;
}
};
// Zustand store
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
Troubleshooting
Common Issues & Solutions
Store not updating components:
// ❌ Wrong: Direct state mutation
const store = useStore.getState();
store.count = 5; // Won't trigger re-renders
// ✅ Correct: Use set function
useStore.setState({ count: 5 });
Too many re-renders:
// ❌ Wrong: Selecting entire store
const store = useStore(); // Re-renders on any change
// ✅ Correct: Select specific values
const count = useStore((state) => state.count);
Async state conflicts:
// ✅ Good: Handle race conditions
const fetchData = async () => {
const controller = new AbortController();
set({ loading: true });
try {
const response = await fetch("/api/data", {
signal: controller.signal,
});
const data = await response.json();
set({ data, loading: false });
} catch (error) {
if (error.name !== "AbortError") {
set({ error: error.message, loading: false });
}
}
return () => controller.abort();
};
Best Practices
1. Keep Stores Focused
// ✅ Good: One store per feature
const useUserStore = create(/* user logic */);
const useCartStore = create(/* cart logic */);
const useNotificationStore = create(/* notification logic */);
// ❌ Avoid: One giant store
const useAppStore = create(/* everything mixed together */);
2. Use Selectors Wisely
// ✅ Good: Select only what you need
const userName = useUserStore((state) => state.user.name);
// ❌ Avoid: Selecting everything
const userStore = useUserStore(); // Causes unnecessary re-renders
3. Handle Async Operations Properly
// ✅ Good: Handle loading and error states
const fetchUser = async (id) => {
set({ loading: true, error: null });
try {
const user = await api.getUser(id);
set({ user, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
};
4. TypeScript Support
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
user: User | null;
isLoggedIn: boolean;
login: (user: User) => void;
logout: () => void;
updateProfile: (updates: Partial<User>) => void;
}
export const useUserStore = create<UserState>((set, get) => ({
user: null,
isLoggedIn: false,
login: (user) => set({ user, isLoggedIn: true }),
logout: () => set({ user: null, isLoggedIn: false }),
updateProfile: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
}));
5. Immer Integration
import { immer } from "zustand/middleware/immer";
const useImmerStore = create(
immer((set) => ({
user: { name: "John", age: 30 },
updateUser: (newData) =>
set((state) => {
state.user = { ...state.user, ...newData }; // Direct mutation!
}),
}))
);
6. Store Composition
// Combine multiple stores
const useCombinedStore = () => ({
...useUserStore(),
...useCartStore(),
...useNotificationStore(),
});
Common Mistakes to Avoid
1. Mutating State Directly
// ❌ Wrong: Direct mutation
const store = useUserStore.getState();
store.user.name = "New Name"; // This won't trigger re-renders!
// ✅ Correct: Use set function
useUserStore.setState((state) => ({
user: { ...state.user, name: "New Name" },
}));
2. Overusing Selectors
// ❌ Wrong: Too many selectors in one component
function Component() {
const user = useUserStore((state) => state.user);
const cart = useCartStore((state) => state.items);
const notifications = useNotificationStore((state) => state.list);
// This component re-renders for any change in any store!
}
// ✅ Better: Combine related data
function Component() {
const userData = useUserStore((state) => ({
user: state.user,
isLoggedIn: state.isLoggedIn,
}));
}
When to Use Zustand
Perfect For:
- Medium to large React applications
- State shared across multiple components
- Complex state logic
- Apps that need good performance
- Teams that want simple, maintainable code
Maybe Not Needed For:
- Very small apps (just use useState)
- Apps with very simple state needs
- When you're already happy with another solution
Quick Reference
Store Creation Patterns
// Simple store
const useSimpleStore = create((set) => ({
value: 0,
setValue: (value) => set({ value }),
}));
// Async store
const useAsyncStore = create((set) => ({
data: null,
loading: false,
fetch: async () => {
set({ loading: true });
const data = await api.getData();
set({ data, loading: false });
},
}));
// Computed values
const useComputedStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
get doubleCount() {
return get().count * 2;
},
}));
Component Usage Patterns
// Single selector
const count = useStore((state) => state.count);
// Multiple selectors
const { count, increment } = useStore((state) => ({
count: state.count,
increment: state.increment,
}));
// Conditional rendering
const isLoading = useStore((state) => state.loading);
if (isLoading) return <Spinner />;
More Examples for Engineers
Form State Management
// stores/formStore.js
import { create } from "zustand";
export const useFormStore = create((set, get) => ({
// Form data
formData: {
name: "",
email: "",
message: "",
},
// Form state
errors: {},
isSubmitting: false,
isDirty: false,
// Actions
updateField: (field, value) =>
set((state) => ({
formData: { ...state.formData, [field]: value },
isDirty: true,
errors: { ...state.errors, [field]: "" }, // Clear field error
})),
validateField: (field) => {
const { formData } = get();
const errors = { ...get().errors };
if (field === "email" && formData.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
errors.email = "Invalid email format";
}
}
if (field === "name" && !formData.name.trim()) {
errors.name = "Name is required";
}
set({ errors });
},
submitForm: async () => {
set({ isSubmitting: true });
try {
// Validate all fields
const { formData } = get();
const errors = {};
if (!formData.name.trim()) errors.name = "Name is required";
if (!formData.email.trim()) errors.email = "Email is required";
if (!formData.message.trim()) errors.message = "Message is required";
if (Object.keys(errors).length > 0) {
set({ errors, isSubmitting: false });
return;
}
// Submit form
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (response.ok) {
set({
formData: { name: "", email: "", message: "" },
errors: {},
isDirty: false,
isSubmitting: false,
});
alert("Form submitted successfully!");
} else {
throw new Error("Submission failed");
}
} catch (error) {
set({
errors: { submit: error.message },
isSubmitting: false,
});
}
},
resetForm: () =>
set({
formData: { name: "", email: "", message: "" },
errors: {},
isDirty: false,
isSubmitting: false,
}),
// Computed properties
get isValid() {
const { formData, errors } = get();
return formData.name && formData.email && formData.message && Object.keys(errors).length === 0;
},
}));
Theme Management
// stores/themeStore.js
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useThemeStore = create(
persist(
(set, get) => ({
theme: "light",
customColors: {
primary: "#3b82f6",
secondary: "#64748b",
},
setTheme: (theme) => {
set({ theme });
// Apply theme to document
document.documentElement.setAttribute("data-theme", theme);
},
toggleTheme: () => {
const { theme } = get();
const newTheme = theme === "light" ? "dark" : "light";
get().setTheme(newTheme);
},
updateCustomColor: (colorType, color) =>
set((state) => ({
customColors: { ...state.customColors, [colorType]: color },
})),
resetToDefaults: () =>
set({
theme: "light",
customColors: {
primary: "#3b82f6",
secondary: "#64748b",
},
}),
// Computed
get isDark() {
return get().theme === "dark";
},
get themeStyles() {
const { theme, customColors } = get();
return {
background: theme === "dark" ? "#1f2937" : "#ffffff",
color: theme === "dark" ? "#f9fafb" : "#111827",
primary: customColors.primary,
secondary: customColors.secondary,
};
},
}),
{
name: "theme-storage",
partialize: (state) => ({
theme: state.theme,
customColors: state.customColors,
}),
}
)
);
Modal/Dialog Management
// stores/modalStore.js
import { create } from "zustand";
export const useModalStore = create((set, get) => ({
modals: {}, // { modalId: { isOpen: boolean, data: any } }
openModal: (modalId, data = null) =>
set((state) => ({
modals: {
...state.modals,
[modalId]: { isOpen: true, data },
},
})),
closeModal: (modalId) =>
set((state) => ({
modals: {
...state.modals,
[modalId]: { isOpen: false, data: null },
},
})),
closeAllModals: () =>
set((state) => {
const closedModals = {};
Object.keys(state.modals).forEach((modalId) => {
closedModals[modalId] = { isOpen: false, data: null };
});
return { modals: closedModals };
}),
// Check if specific modal is open
isModalOpen: (modalId) => {
const { modals } = get();
return modals[modalId]?.isOpen || false;
},
// Get modal data
getModalData: (modalId) => {
const { modals } = get();
return modals[modalId]?.data || null;
},
// Update modal data
updateModalData: (modalId, data) =>
set((state) => ({
modals: {
...state.modals,
[modalId]: {
...state.modals[modalId],
data: { ...state.modals[modalId]?.data, ...data },
},
},
})),
}));
Notification System
// stores/notificationStore.js
import { create } from "zustand";
export const useNotificationStore = create((set, get) => ({
notifications: [], // Array of { id, type, message, duration, timestamp }
addNotification: (notification) => {
const id = Date.now().toString();
const newNotification = {
id,
type: "info", // info, success, warning, error
duration: 5000, // Auto-dismiss after 5 seconds
timestamp: Date.now(),
...notification,
};
set((state) => ({
notifications: [...state.notifications, newNotification],
}));
// Auto-dismiss
if (newNotification.duration > 0) {
setTimeout(() => {
get().removeNotification(id);
}, newNotification.duration);
}
return id;
},
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
clearAllNotifications: () => set({ notifications: [] }),
// Convenience methods
showSuccess: (message, duration = 5000) =>
get().addNotification({ type: "success", message, duration }),
showError: (message, duration = 7000) =>
get().addNotification({ type: "error", message, duration }),
showWarning: (message, duration = 6000) =>
get().addNotification({ type: "warning", message, duration }),
showInfo: (message, duration = 5000) =>
get().addNotification({ type: "info", message, duration }),
// Get notifications by type
getNotificationsByType: (type) => {
const { notifications } = get();
return notifications.filter((n) => n.type === type);
},
// Get active notifications count
get activeCount() {
return get().notifications.length;
},
}));
API Data Fetching with Cache
// stores/apiStore.js
import { create } from "zustand";
export const useApiStore = create((set, get) => ({
cache: new Map(), // Cache for API responses
requests: new Set(), // Track ongoing requests
// Generic fetch with cache
fetchWithCache: async (url, options = {}) => {
const cacheKey = `${options.method || "GET"}-${url}`;
// Check cache first
if (get().cache.has(cacheKey) && !options.skipCache) {
const cached = get().cache.get(cacheKey);
if (Date.now() - cached.timestamp < (options.cacheTime || 300000)) {
// 5 min default
return cached.data;
}
}
// Check if request is already in progress
if (get().requests.has(cacheKey)) {
// Wait for existing request
return new Promise((resolve) => {
const checkRequest = () => {
if (!get().requests.has(cacheKey)) {
const cached = get().cache.get(cacheKey);
resolve(cached?.data);
} else {
setTimeout(checkRequest, 100);
}
};
checkRequest();
});
}
// Make new request
get().requests.add(cacheKey);
try {
const response = await fetch(url, options);
const data = await response.json();
if (response.ok) {
// Cache successful response
set((state) => ({
cache: new Map(state.cache).set(cacheKey, {
data,
timestamp: Date.now(),
}),
}));
}
return data;
} finally {
set((state) => ({
requests: new Set([...state.requests].filter((r) => r !== cacheKey)),
}));
}
},
// Specific API methods
fetchUsers: async (params = {}) => {
const queryString = new URLSearchParams(params).toString();
const url = `/api/users${queryString ? `?${queryString}` : ""}`;
return get().fetchWithCache(url);
},
fetchUserById: async (id) => {
return get().fetchWithCache(`/api/users/${id}`);
},
createUser: async (userData) => {
const result = await get().fetchWithCache("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData),
skipCache: true, // Don't cache POST requests
});
// Invalidate users list cache
set((state) => {
const newCache = new Map(state.cache);
newCache.delete("GET-/api/users");
return { cache: newCache };
});
return result;
},
// Clear cache
clearCache: () => set({ cache: new Map() }),
// Get cache stats
get cacheStats() {
const { cache } = get();
return {
size: cache.size,
keys: Array.from(cache.keys()),
};
},
}));
Authentication Store
// stores/authStore.js
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useAuthStore = create(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
const data = await response.json();
if (response.ok) {
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
});
// Set token in localStorage for API calls
localStorage.setItem("auth-token", data.token);
} else {
throw new Error(data.message || "Login failed");
}
} catch (error) {
set({
error: error.message,
isLoading: false,
});
throw error;
}
},
register: async (userData) => {
set({ isLoading: true, error: null });
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData),
});
const data = await response.json();
if (response.ok) {
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
});
} else {
throw new Error(data.message || "Registration failed");
}
} catch (error) {
set({
error: error.message,
isLoading: false,
});
throw error;
}
},
logout: () => {
localStorage.removeItem("auth-token");
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
});
},
refreshToken: async () => {
try {
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: {
Authorization: `Bearer ${get().token}`,
},
});
if (response.ok) {
const data = await response.json();
set({ token: data.token });
localStorage.setItem("auth-token", data.token);
} else {
// Token refresh failed, logout user
get().logout();
}
} catch (error) {
get().logout();
}
},
updateProfile: async (updates) => {
set({ isLoading: true, error: null });
try {
const response = await fetch("/api/auth/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${get().token}`,
},
body: JSON.stringify(updates),
});
const data = await response.json();
if (response.ok) {
set({
user: { ...get().user, ...data.user },
isLoading: false,
});
} else {
throw new Error(data.message || "Update failed");
}
} catch (error) {
set({
error: error.message,
isLoading: false,
});
throw error;
}
},
// Check if user has specific role/permission
hasRole: (role) => {
const { user } = get();
return user?.roles?.includes(role) || false;
},
hasPermission: (permission) => {
const { user } = get();
return user?.permissions?.includes(permission) || false;
},
// Get user display name
get displayName() {
const { user } = get();
return user ? `${user.firstName} ${user.lastName}` : "";
},
// Check if token is expired
get isTokenExpired() {
const { token } = get();
if (!token) return true;
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.exp * 1000 < Date.now();
} catch {
return true;
}
},
}),
{
name: "auth-storage",
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
Frequently Asked Questions
Is Zustand better than Redux?
It depends on your needs. Zustand is simpler and requires less boilerplate than Redux, making it better for most React apps. Redux is more powerful for very complex state logic or when you need advanced debugging tools. Choose Zustand for 80% of use cases, Redux for the remaining 20%.
How does Zustand compare to Context API?
Zustand is more powerful. Context API works for simple cases but can cause unnecessary re-renders. Zustand offers better performance, middleware support, and easier testing. Use Context for very simple state, Zustand for anything more complex.
Can I use Zustand with TypeScript?
Absolutely! Zustand has excellent TypeScript support. Define interfaces for your state and actions, and you'll get full type safety. The examples in this article show proper TypeScript usage.
Is Zustand production-ready?
Yes, definitely. Zustand is used by many production applications and has a stable API. It's maintained by the React community and has excellent documentation.
How do I handle complex state with Zustand?
Use multiple stores or nested objects. Split complex state into focused stores (user, cart, notifications). For deeply nested state, use Immer middleware for easier mutations.
Can I use multiple stores?
Yes, and you should! Multiple stores are encouraged for better organization. Each store should handle one domain of your app (auth, cart, notifications, etc.).
How do I test Zustand stores?
Use React Testing Library. Test your stores by rendering hooks and checking state changes. Mock external dependencies and test both sync and async actions.
Is Zustand performant?
Very performant. Zustand uses selectors to prevent unnecessary re-renders and has minimal overhead. It's often faster than Redux due to less boilerplate.
Can I persist state with Zustand?
Yes, with middleware. Use the persist middleware to automatically save state to localStorage or sessionStorage. It's perfect for user preferences and app state.
How do I migrate from Redux to Zustand?
Replace reducers with stores. Convert Redux actions to Zustand store methods. Remove action types and creators. The migration guide in this article shows the exact steps.
Can I use Zustand with class components?
Yes, but it's designed for hooks. You can use useStore in class components, but functional components with hooks work better. Consider migrating to functional components.
Does Zustand work with React Native?
Yes! Zustand works perfectly with React Native. The same API and patterns apply. It's a great alternative to Redux for mobile apps.
How do I handle side effects in Zustand?
Use async actions. Create async functions in your store that handle API calls, then update state when complete. Always handle loading and error states.
Can I use Zustand with Next.js SSR?
Yes, with care. Zustand stores work with SSR, but avoid initializing state with browser-only APIs. Use dynamic imports or conditional logic for client-side features.
How do I debug Zustand stores?
Use devtools middleware. It integrates with Redux DevTools for time-travel debugging. You can also add console logs or use React DevTools.
Is Zustand opinionated?
Minimally opinionated. Zustand gives you freedom to structure your state however you want. It provides the tools but doesn't enforce patterns like Redux does.
Can I use Zustand with other state libraries?
Yes, but usually not needed. Zustand can work alongside Context or Redux if you have legacy code, but it's powerful enough to replace them entirely.
How do I handle state subscriptions?
Use selectors. Zustand automatically handles subscriptions. Components only re-render when the selected state changes, making it very efficient.
What's the learning curve for Zustand?
Very low. If you know React hooks, you can learn Zustand in an hour. It's much simpler than Redux or MobX.
Can I use Zustand with React 18 features?
Yes, fully compatible. Zustand works great with React 18's concurrent features, Suspense, and Server Components.
Final Thoughts
Zustand is like that perfect tool you didn't know you needed. It's simple enough for beginners but powerful enough for complex applications. If you're tired of prop drilling or Redux boilerplate, give Zustand a try!
Remember:
- Start small with one store
- Keep your stores focused on specific features
- Use selectors to optimize performance
- Handle async operations properly
Happy coding! 🎉
Quick Start Template
Here's a complete example you can copy and modify:
// stores/counterStore.js
import { create } from "zustand";
export const useCounterStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
// Computed values
get doubleCount() {
return get().count * 2;
},
}));
// components/Counter.js
import { useCounterStore } from "../stores/counterStore";
function Counter() {
const { count, increment, decrement, reset, doubleCount } = useCounterStore();
return (
<div>
<h1>Count: {count}</h1>
<h2>Double: {doubleCount}</h2>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
That's it! You now have a complete state management solution that's simple, powerful, and easy to understand. 🚀
I build and scale reliable production systems. Open to full-time and freelance work with U.S.-based teams that value ownership and execution.
Got something in mind?
Book a Discovery Call