
Zustand Deep Dive: Practical Patterns for Global State Management
Switched from Redux to Zustand and cut boilerplate by 90%. But using it properly requires understanding some key patterns.

Switched from Redux to Zustand and cut boilerplate by 90%. But using it properly requires understanding some key patterns.
Optimizing by gut feeling made my app slower. Learn to use Performance profiler to find real bottlenecks and fix what matters.

Text to Binary (HTTP/2), TCP to UDP (HTTP/3). From single-file queueing to parallel processing. Google's QUIC protocol story.

From HTML parsing to DOM, CSSOM, Render Tree, Layout, Paint, and Composite. Mastering the Critical Rendering Path (CRP), Preload Scanner, Reflow vs Repaint, and requestAnimationFrame.

Obsessively wrapping everything in `useMemo`? It might be hurting your performance. Learn the hidden costs of memoization and when to actually use it.

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.
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."
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.
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.
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
}),
}
)
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...
},
});
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.
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.
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);
});
Comparing libraries that emerged around the same time:
Zustand: Flux pattern, centralized store, familiar API
Jotai: Atomic pattern, composition of small state units
Valtio: Proxy-based, mutable API
In my experience, Zustand was the best Redux replacement. Familiar patterns, easy team onboarding.
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
Step 3: Remove Provider
Result: 90% less boilerplate, 2x development speed, smaller bundle.
The "excessive" feeling I had with Redux doesn't exist with Zustand. It has everything you need while staying simple.
Core patterns summarized:
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:
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.