Advanced Zustand Patterns: Slices, Nested State, and Middleware Mastery

  • Home
  • Zustand
  • Advanced Zustand Patterns: Slices, Nested State, and Middleware Mastery
Front
Back
Right
Left
Top
Bottom
BEYOND
Beyond Basics

Scaling Zustand for Complex Applications

Simple counters are cute, but real applications need sophisticated state organization. This blog covers patterns that separate junior from senior developers: slices, nested state management, middleware composition, and computed values. The Zustand documentation recommends splitting stores into separate slices for better code maintenance and organization.
#1
Pattern 1

Slices for Modular State

As your app grows, stuffing everything into one store becomes messy. Slices let you organize state by domain while keeping the benefits of a single store.
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

#2
Pattern 2

Nested State with Immer

Updating nested state immutably is tedious. The `immer` middleware solves this.
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
    }),
  }))
)
Immer uses JavaScript Proxies to track mutations and create immutable updates automatically. The “Taking Zustand Further” article by ash demonstrates how immer simplifies nested updates while maintaining immutability.
#2
Pattern 3

Computed Values & Derived State

Don’t store what you can calculate. Computed values prevent data duplication and sync issues.
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>
}
The Zustand documentation notes that derived values should be computed from existing state to keep stores minimal, with TypeScript ensuring safe calculations.
#4
Pattern 4

Actions vs Direct Updates

Should you call `set()` directly or create action methods? Both work, but actions are cleaner.
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.
#5
Pattern 5

Middleware Composition

Stack multiple middlewares for powerful combinations. Order matters!
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)
The Zustand GitHub repository shows that middlewares can mutate the store, with proper typing ensured through “higher-kinded mutators” for complex scenarios.

Explore project snapshots or discuss custom web solutions.

REAL WOLD
Real-World Exampl

Shopping Cart

Combining patterns for a production-ready 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

Common Pitfalls

Always code as if the person who ends up maintaining your code is a violent psychopath who knows where you live.

John Wood, Clean Code discussions
FAQ's

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