Scaling Zustand for Complex Applications
Slices for Modular State
The Problem: Monolithic Stores
// BAD: Everything in one blob
const useStore = create((set) => ({
// User stuff
user: null,
login: () => {},
logout: () => {},
// Cart stuff
items: [],
addItem: () => {},
removeItem: () => {},
// UI stuff
theme: 'light',
toggleTheme: () => {},
// ... 100 more lines
}))
The Solution: Separate Slices
// store/slices/userSlice.js
export const createUserSlice = (set) => ({
user: null,
isAuthenticated: false,
login: async (credentials) => {
const user = await authAPI.login(credentials)
set({ user, isAuthenticated: true })
},
logout: () => set({ user: null, isAuthenticated: false }),
})
// store/slices/cartSlice.js
export const createCartSlice = (set) => ({
items: [],
total: 0,
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id),
total: state.items.find(i => i.id === id)?.price || 0,
})),
})
// store/index.js
import { create } from 'zustand'
import { createUserSlice } from './slices/userSlice'
import { createCartSlice } from './slices/cartSlice'
const useStore = create((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get),
}))
export default useStore
Benefits
- Each slice is testable independently
- Clear separation of concerns
- Easy to add new features without touching existing code
Nested State with Immer
Without Immer (Painful)
// store/slices/userSlice.js
export const createUserSlice = (set) => ({
user: null,
isAuthenticated: false,
login: async (credentials) => {
const user = await authAPI.login(credentials)
set({ user, isAuthenticated: true })
},
logout: () => set({ user: null, isAuthenticated: false }),
})
// store/slices/cartSlice.js
export const createCartSlice = (set) => ({
items: [],
total: 0,
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id),
total: state.items.find(i => i.id === id)?.price || 0,
})),
})
// store/index.js
import { create } from 'zustand'
import { createUserSlice } from './slices/userSlice'
import { createCartSlice } from './slices/cartSlice'
const useStore = create((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get),
}))
export default useStore
With Immer (Beautiful)
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: {
profile: {
settings: {
notifications: {
email: true,
push: false,
}
}
}
},
// Much cleaner with immer
toggleEmail: () => set((state) => {
state.user.profile.settings.notifications.email =
!state.user.profile.settings.notifications.email
}),
}))
)
Computed Values & Derived State
Wrong: Storing Derived Data
const useStore = create((set) => ({
items: [],
totalItems: 0, // Redundant
totalPrice: 0, // Can be calculated
addItem: (item) => set((state) => ({
items: [...state.items, item],
totalItems: state.totalItems + 1, // Easy to forget!
totalPrice: state.totalPrice + item.price,
})),
}))
Right: Calculate in Selectors
const useStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item],
})),
}))
// In components
function CartSummary() {
const totalItems = useStore((state) => state.items.length)
const totalPrice = useStore((state) =>
state.items.reduce((sum, item) => sum + item.price, 0)
)
return <div>Items: {totalItems}, Total: ${totalPrice}</div>
}
Actions vs Direct Updates
Direct Updates (Quick & Dirty)
const setTheme = useStore((state) => state.set)
setTheme({ theme: 'dark' }) // Works but not semantic
```
### Action Methods (Professional)
```javascript
const useStore = create((set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
}))
// In component
const toggleTheme = useStore((state) => state.toggleTheme)
toggleTheme() // Clear intent
Recommended
Use actions for business logic, direct `set()` for simple assignments.
Middleware Composition
Direct Updates (Quick & Dirty)
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
const useStore = create(
devtools(
persist(
immer((set) => ({
user: null,
preferences: {
theme: 'light',
notifications: true,
},
updatePreferences: (updates) => set((state) => {
Object.assign(state.preferences, updates)
}),
})),
{ name: 'app-storage' }
)
)
)
Middleware order (outside to inside)
- `devtools` - Debugging (outermost)
- `persist` - Persistence
- `immer` - Mutation tracking (innermost)
Explore project snapshots or discuss custom web solutions.
Shopping Cart
// store/useCartStore.js
// npm install zustand
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
// npm install immer
import { immer } from 'zustand/middleware/immer'
const useCartStore = create(
devtools(
persist(
immer((set, get) => ({
items: [],
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id)
if (existing) {
existing.quantity += 1
} else {
state.items.push({ ...product, quantity: 1 })
}
}),
removeItem: (id) => set((state) => {
state.items = state.items.filter(item => item.id !== id)
}),
updateQuantity: (id, quantity) => set((state) => {
const item = state.items.find(i => i.id === id)
if (item) item.quantity = quantity
}),
clearCart: () => set({ items: [] }),
// Computed via selector
getTotal: () => {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
})),
{ name: 'shopping-cart' }
),
{ name: 'CartStore' }
)
)
export default useCartStore
// App.jsx
import React from 'react'
import useCartStore from './store/useCartStore'
// Mock products
const PRODUCTS = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 29 },
{ id: 3, name: 'Keyboard', price: 79 },
{ id: 4, name: 'Monitor', price: 299 },
]
function App() {
// Component re-renders when these values change
const items = useCartStore((state) => state.items)
const addItem = useCartStore((state) => state.addItem)
const removeItem = useCartStore((state) => state.removeItem)
const updateQuantity = useCartStore((state) => state.updateQuantity)
const clearCart = useCartStore((state) => state.clearCart)
const getTotal = useCartStore((state) => state.getTotal)
return (
<div>
{/* Products list */}
<h1>Products</h1>
{PRODUCTS.map((product) => (
<div key={product.id}>
<span>{product.name} - {product.price}</span>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
))}
{/* Cart */}
<h1>Shopping Cart</h1>
{items.length === 0 ? (
<p>Cart is empty</p>
) : (
<>
{items.map((item) => (
<div key={item.id}>
<span>{item.name} - {item.price}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
min="1"
/>
<span>Subtotal: {item.price * item.quantity}</span>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<h2>Total: {getTotal()}</h2>
<button onClick={clearCart}>Clear Cart</button>
</>
)}
</div>
)
}
export default App
Performance Tips
- Keep Slices Focused - One slice = one domain
- Use Immer Wisely - Great for deep updates, overkill for shallow ones
- Compute on Read - Derive values in selectors, not store state
- Middleware Order Test different orders if you see issues
Common Pitfalls
- Over-slicing: Creating 50 micro-slices adds complexity
- Sweet Spot: 3-7 slices per store
- Storing Everything: Don't persist temporary UI state
- Be Selective: Persist only user data, preferences, auth
Always code as if the person who ends up maintaining your code is a violent psychopath who knows where you live.
Frequently Asked Questions
Use slices first. Only create separate stores when domains are truly independent (e.g., user auth store + analytics store). Multiple stores add coordination overhead.
Minimally. The overhead is negligible for most apps. Avoid it for high-frequency updates (60fps animations). For typical CRUD operations, immer's readability wins.
Yes! Use the `get` parameter: `createUserSlice = (set, get) => ({ logout: () => { get().clearCart(); set({user: null})} })`. But avoid circular dependencies.
Create unwrapped stores for tests. Export both the wrapped (production) and unwrapped (test) versions. We'll cover this in Blog 4.
Absolutely. Zustand doesn't care if actions are async. Just use `async/await` normally: `login: async (creds) => { const user = await api.login(creds); set({user}) }`.
Comments are closed