Zustand Deep Dive: Practical Patterns for Global State Management
Prologue: I Wanted to Escape Redux
I was using Redux in my project, and honestly, it drove me crazy every time I needed to add a piece of state. Create action creators, build reducers, define types, connect dispatch... just for a simple counter, I had to touch five different files.
"Is this really the right way?" I wondered, so I started looking for alternatives and discovered Zustand. My first impression was "Can it really be this simple?" A 20-line Redux boilerplate became 5 lines in Zustand. No Provider, no Context Hell.
But as I used it in production, I realized there were things I needed to know. Performance optimization, middleware usage, structuring large apps... without understanding these, I'd just create a different kind of spaghetti code. So I documented everything. The practical Zustand patterns I learned through trial and error.
Aha! Moment: The Store is Just a Function
The most shocking thing about moving from Redux to Zustand was that the store is just a function.
Redux looked like this:
// actions.ts
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
// reducer.ts
const counterReducer = (state = { count: 0 }, action) => {
switch (action.type) {
case INCREMENT:
return { count: state.count + 1 };
case DECREMENT:
return { count: state.count - 1 };
default:
return state;
}
};
// In Component
import { useDispatch, useSelector } from 'react-redux';
const dispatch = useDispatch();
const count = useSelector(state => state.counter.count);
dispatch(increment());
Zustand looks like this:
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
// In Component
const { count, increment } = useCounterStore();
increment();
One file, one function, done. No Provider needed. Just import and use it.
This works because Zustand stores exist outside React as singletons. Unlike Redux, you don't need to pass them through Context. You just call them like global functions from anywhere.
This realization was a game changer. I could see state management not as a "massive architecture" but as a "tool you use where needed."
Deep Dive: Zustand Patterns Done Right
1. Minimizing Re-renders with Selectors
Initially, I wrote this:
const useUserStore = create((set) => ({
user: { name: 'John', email: 'john@example.com', age: 30 },
updateUser: (data) => set({ user: data }),
}));
// In Component
function ProfileName() {
const store = useUserStore(); // Gets entire store
return <div>{store.user.name}</div>;
}
The problem? When user.age changes, ProfileName re-renders too. Unnecessary update.
The solution is selectors:
function ProfileName() {
const userName = useUserStore((state) => state.user.name); // Only what's needed
return <div>{userName}</div>;
}
Now it only re-renders when user.name changes. Zustand detects changes using shallow comparison.
More complex selectors are possible:
const fullName = useUserStore((state) =>
`${state.user.firstName} ${state.user.lastName}`
);
It's like ordering at a restaurant: instead of asking for the entire menu, you say "just today's soup, please." You get only what you need, so it's efficient.
2. Ending Immutability Worries with Immer Middleware
Updating nested objects was hell:
const useCartStore = create((set) => ({
items: [
{ id: 1, name: 'Apple', quantity: 2 },
{ id: 2, name: 'Banana', quantity: 1 },
],
updateQuantity: (id, quantity) => set((state) => ({
items: state.items.map(item =>
item.id === id ? { ...item, quantity } : item
),
})),
}));
With Immer middleware:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useCartStore = create(
immer((set) => ({
items: [
{ id: 1, name: 'Apple', quantity: 2 },
{ id: 2, name: 'Banana', quantity: 1 },
],
updateQuantity: (id, quantity) => set((state) => {
const item = state.items.find(i => i.id === id);
if (item) item.quantity = quantity; // Just mutate!
}),
}))
);
Immutability is magically preserved. Immer tracks changes with proxies behind the scenes and creates new objects. It's like editing in Draft mode—when you hit "Publish," a new version appears.
3. Surviving Refreshes with Persist Middleware
Some state needs to persist, like user settings or shopping carts:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
language: 'ko',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // localStorage key
storage: createJSONStorage(() => localStorage),
}
)
);
Now settings persist across page refreshes. You can also use sessionStorage or custom storage.
To persist only certain fields:
persist(
(set) => ({ /* ... */ }),
{
name: 'app-settings',
partialize: (state) => ({
theme: state.theme,
language: state.language,
// Exclude sensitive data like tokens
}),
}
)
4. Structuring Large Apps with Store Slicing
As apps grow, you can't dump everything into one store. Use the slice pattern to separate concerns:
// userSlice.ts
export const createUserSlice = (set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
});
// cartSlice.ts
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
});
// store.ts
import { create } from 'zustand';
import { createUserSlice } from './userSlice';
import { createCartSlice } from './cartSlice';
const useStore = create((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}));
Now each slice is managed independently, but accessible from one store. Like dividing a big company into departments while keeping them in the same building.
If slices need to interact:
export const createCartSlice = (set, get) => ({
items: [],
checkout: () => {
const user = get().user; // Access another slice's state
if (!user) {
alert('Please log in');
return;
}
// checkout logic...
},
});
5. Async Operations and API Calls
Pattern for handling API calls in stores:
const usePostsStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/posts');
const posts = await response.json();
set({ posts, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
createPost: async (post) => {
set({ loading: true });
try {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(post),
});
const newPost = await response.json();
set((state) => ({
posts: [...state.posts, newPost],
loading: false,
}));
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
// In Component
function PostList() {
const { posts, loading, fetchPosts } = usePostsStore();
useEffect(() => {
fetchPosts();
}, [fetchPosts]);
if (loading) return <div>Loading...</div>;
return posts.map(post => <Post key={post.id} {...post} />);
}
The advantage: loading/error states live in the store and can be shared across components.
6. Debugging with Devtools Middleware
You can use Redux DevTools with Zustand:
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
}),
{ name: 'CounterStore' }
)
);
Now you can visually track state changes in browser DevTools. Time-travel debugging works too.
To combine multiple middleware:
const useStore = create(
devtools(
persist(
immer((set) => ({
// store definition
})),
{ name: 'my-store' }
),
{ name: 'MyStore' }
)
);
Order matters. Usually wrap devtools → persist → immer.
7. Testing Zustand Stores
Stores are independent of React, making tests easy:
import { renderHook, act } from '@testing-library/react';
import { useCounterStore } from './counterStore';
describe('CounterStore', () => {
beforeEach(() => {
// Reset store before each test
useCounterStore.setState({ count: 0 });
});
it('should increment count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(-1);
});
});
You can also manipulate stores directly:
it('should handle async operations', async () => {
const store = usePostsStore.getState();
await store.fetchPosts();
expect(store.posts.length).toBeGreaterThan(0);
expect(store.loading).toBe(false);
});
8. Zustand vs Jotai vs Valtio
Comparing libraries that emerged around the same time:
Zustand: Flux pattern, centralized store, familiar API
- Pros: Simple and intuitive, easy Redux migration
- Cons: Focused on global state (overkill for local state)
Jotai: Atomic pattern, composition of small state units
- Pros: Fine-grained optimization, React Suspense integration
- Cons: Different mental model, learning curve
Valtio: Proxy-based, mutable API
- Pros: Really intuitive (just mutate objects)
- Cons: Issues in non-proxy environments, harder debugging
In my experience, Zustand was the best Redux replacement. Familiar patterns, easy team onboarding.
9. Migrating from Redux to Zustand
My actual migration process:
Step 1: Start with simple slices
// Old Redux
const userReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
default:
return state;
}
};
// Convert to Zustand
const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
Step 2: Gradual replacement
- Run Redux and Zustand simultaneously
- Write new features with Zustand
- Migrate legacy when you have time
Step 3: Remove Provider
- Once all Redux stores are gone, delete Provider
- Saw 20% bundle size reduction
Result: 90% less boilerplate, 2x development speed, smaller bundle.
Summary: Zustand is the 80/20 Solution for State Management
The "excessive" feeling I had with Redux doesn't exist with Zustand. It has everything you need while staying simple.
Core patterns summarized:
- Use selectors: Prevent unnecessary re-renders
- Update with Immer: Handle nested objects easily
- Persist with middleware: Auto-sync localStorage
- Slice for scale: Manage large apps
- Debug with Devtools: Use Redux DevTools as-is
State management doesn't need to be complex. You just need the features that matter. Zustand covers 80% with 20% of the effort.
Finally, here's how to choose:
- Simple global state needed: Zustand
- Fine-grained atomic pattern: Jotai
- Really simple object mutation: Valtio
- Existing Redux team: Redux Toolkit (no need to force change)
For me, Zustand was the perfect fit. I wanted Redux's structure and patterns but with minimal boilerplate. And that's exactly what I got.