Zustand Best Practices: How to Structure Stores, Avoid Re-renders, and Keep Things Clean
ZustandReactBest Practices

Zustand Best Practices: How to Structure Stores, Avoid Re-renders, and Keep Things Clean

January 24, 2025
9 min read
Salman Izhar

Zustand Best Practices: How to Structure Stores, Avoid Re-renders, and Keep Things Clean

You've built your first Zustand store. It works. Life is good.

Then your app grows. Your store gets bigger. You add more state. More actions. More complexity.

And suddenly, that simple 20-line store is now 200 lines. Components re-render when they shouldn't. Things feel messy.

I've been there. Let me share what I learned.

Pattern 1: Split Large Stores into Slices

The first thing that happens as your app grows is that your single store becomes a dumping ground for everything.

User data. Settings. Notifications. Cart. Theme. All in one giant object.

Don't do that.

The Problem

const useStore = create((set) => ({
  // User stuff
  user: null,
  isAuthenticated: false,
  login: () => {},
  logout: () => {},
  
  // Cart stuff
  cartItems: [],
  addToCart: () => {},
  removeFromCart: () => {},
  
  // Settings stuff
  theme: 'light',
  notifications: true,
  updateSettings: () => {},
  
  // ... 50 more lines
}))

This works, but it's messy. Everything updates everything. It's hard to find what you need.

The Solution: Separate Stores

// stores/useUserStore.js
export const useUserStore = create((set) => ({
  user: null,
  isAuthenticated: false,
  
  login: async (credentials) => {
    const user = await api.login(credentials)
    set({ user, isAuthenticated: true })
  },
  
  logout: () => set({ user: null, isAuthenticated: false }),
}))

// stores/useCartStore.js
export const useCartStore = create((set) => ({
  items: [],
  
  addItem: (product) => set((state) => ({
    items: [...state.items, product]
  })),
  
  removeItem: (id) => set((state) => ({
    items: state.items.filter(item => item.id !== id)
  })),
}))

// stores/useSettingsStore.js
export const useSettingsStore = create((set) => ({
  theme: 'light',
  notifications: true,
  
  setTheme: (theme) => set({ theme }),
  toggleNotifications: () => set((state) => ({
    notifications: !state.notifications
  })),
}))

Now each store has one responsibility. Easier to understand. Easier to maintain. Easier to test.

Pattern 2: Use Selectors to Prevent Re-renders

This is huge. And most people get it wrong at first.

When you do this:

function MyComponent() {
  const store = useStore() // Gets entire store
  
  return 
{store.user.name}
}

Your component re-renders whenever ANYTHING in the store changes. Even if you only use user.name.

Cart item added? Re-render. Theme changed? Re-render. Notification dismissed? Re-render.

The Fix: Select Only What You Need

function MyComponent() {
  // Only subscribe to user.name
  const userName = useStore((state) => state.user.name)
  
  return 
{userName}
}

Now this component only re-renders when user.name changes. Nothing else.

For Multiple Values

function UserProfile() {
  // Subscribe to multiple values
  const { name, email, avatar } = useStore((state) => ({
    name: state.user.name,
    email: state.user.email,
    avatar: state.user.avatar,
  }))
  
  return (
    

{name}

{email}

) }

Zustand is smart enough to do a shallow comparison. If these three values don't change, the component doesn't re-render.

Pattern 3: Keep Actions Simple

I see this mistake a lot. People try to make their actions do too much.

Don't Do This

const useStore = create((set, get) => ({
  todos: [],
  
  addTodo: (text) => set((state) => {
    // Too much logic in the action
    const newTodo = {
      id: Math.random().toString(36).substr(2, 9),
      text: text.trim(),
      done: false,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      priority: determinePriority(text),
      tags: extractTags(text),
    }
    
    const updatedTodos = [...state.todos, newTodo]
    
    // Side effects inside action
    localStorage.setItem('todos', JSON.stringify(updatedTodos))
    analytics.track('todo_added')
    
    return { todos: updatedTodos }
  }),
}))

This works, but it's doing too much. Hard to test. Hard to reason about.

Do This Instead

// utils/todoHelpers.js
export const createTodo = (text) => ({
  id: Math.random().toString(36).substr(2, 9),
  text: text.trim(),
  done: false,
  createdAt: new Date().toISOString(),
  updatedAt: new Date().toISOString(),
  priority: determinePriority(text),
  tags: extractTags(text),
})

// stores/useTodoStore.js
const useTodoStore = create((set) => ({
  todos: [],
  
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, createTodo(text)]
  })),
}))

// In your component
function addTodoHandler(text) {
  useTodoStore.getState().addTodo(text)
  localStorage.setItem('todos', JSON.stringify(useTodoStore.getState().todos))
  analytics.track('todo_added')
}

Now the store action is simple. The helper function is testable. Side effects are in the component where you can see them.

Pattern 4: Computed Values

Sometimes you need derived state. Don't store it-compute it.

Don't Store Derived State

// Bad
const useCartStore = create((set) => ({
  items: [],
  total: 0, // Don't do this!
  
  addItem: (item) => set((state) => {
    const newItems = [...state.items, item]
    const newTotal = newItems.reduce((sum, i) => sum + i.price, 0)
    return { items: newItems, total: newTotal }
  }),
}))

Compute It On Read

// Good
const useCartStore = create((set, get) => ({
  items: [],
  
  addItem: (item) => set((state) => ({
    items: [...state.items, item]
  })),
  
  getTotal: () => {
    const items = get().items
    return items.reduce((sum, item) => sum + item.price, 0)
  },
}))

// In component
function CartTotal() {
  const getTotal = useCartStore((state) => state.getTotal)
  const total = getTotal()
  
  return 
Total: ${total}
}

Or even better, use a selector:

function CartTotal() {
  const total = useCartStore((state) =>
    state.items.reduce((sum, item) => sum + item.price, 0)
  )
  
  return 
Total: ${total}
}

This component only re-renders when the total changes, even if items are reordered.

Group related actions together. Makes your store easier to navigate.

const useUserStore = create((set) => ({
  user: null,
  isLoading: false,
  error: null,
  
  // Auth actions
  auth: {
    login: async (credentials) => {
      set({ isLoading: true, error: null })
      try {
        const user = await api.login(credentials)
        set({ user, isLoading: false })
      } catch (error) {
        set({ error: error.message, isLoading: false })
      }
    },
    
    logout: () => set({ user: null }),
    
    refresh: async () => {
      const user = await api.getCurrentUser()
      set({ user })
    },
  },
  
  // Profile actions
  profile: {
    update: async (data) => {
      const user = await api.updateProfile(data)
      set({ user })
    },
    
    uploadAvatar: async (file) => {
      const avatar = await api.uploadAvatar(file)
      set((state) => ({ user: { ...state.user, avatar } }))
    },
  },
}))

// Usage
const { login } = useUserStore((state) => state.auth)
const { update } = useUserStore((state) => state.profile)

Clean. Organized. Easy to find what you need.

Pattern 6: TypeScript for Clarity

If you're using TypeScript, define your store interface. Makes everything clearer.

interface User {
  id: string
  name: string
  email: string
}

interface UserStore {
  user: User | null
  isLoading: boolean
  error: string | null
  
  login: (credentials: { email: string; password: string }) => Promise
  logout: () => void
  updateProfile: (data: Partial) => Promise
}

const useUserStore = create((set) => ({
  user: null,
  isLoading: false,
  error: null,
  
  login: async (credentials) => {
    set({ isLoading: true })
    const user = await api.login(credentials)
    set({ user, isLoading: false })
  },
  
  logout: () => set({ user: null }),
  
  updateProfile: async (data) => {
    const user = await api.updateProfile(data)
    set({ user })
  },
}))

Now you get autocomplete. Type safety. Fewer bugs.

Pattern 7: Middleware for Persistence

Need to save state to localStorage? Use the persist middleware.

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'app-settings', // localStorage key
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
      }), // Only persist these fields
    }
  )
)

Now settings persist across page refreshes. Automatic.

Pattern 8: Async Actions with Loading States

Handle async operations properly. Show loading. Handle errors.

const useDataStore = create((set) => ({
  data: null,
  isLoading: false,
  error: null,
  
  fetchData: async () => {
    set({ isLoading: true, error: null })
    
    try {
      const data = await api.fetchData()
      set({ data, isLoading: false })
    } catch (error) {
      set({ error: error.message, isLoading: false })
    }
  },
}))

// In component
function DataDisplay() {
  const { data, isLoading, error, fetchData } = useDataStore()
  
  useEffect(() => {
    fetchData()
  }, [])
  
  if (isLoading) return 
  if (error) return 
  if (!data) return null
  
  return 
{data.content}
}

Clear states. Easy to reason about. Works every time.

Pattern 9: Reset Functions

Sometimes you need to reset parts of your store.

const initialState = {
  filters: {
    search: '',
    category: 'all',
    sortBy: 'date',
  },
}

const useProductStore = create((set) => ({
  ...initialState,
  products: [],
  
  setFilter: (key, value) => set((state) => ({
    filters: { ...state.filters, [key]: value }
  })),
  
  resetFilters: () => set({ filters: initialState.filters }),
  
  reset: () => set(initialState),
}))

Makes cleanup easy. Useful for testing too.

Common Mistakes to Avoid

Mistake 1: Subscribing to the Entire Store

// Bad - re-renders on any change
const store = useStore()

// Good - only re-renders when userName changes
const userName = useStore((state) => state.user.name)

Mistake 2: Creating New Objects in Selectors

// Bad - creates new object on every render, causes re-renders
const userInfo = useStore((state) => ({ name: state.name, email: state.email }))

// Good - use shallow equality or separate selectors
const name = useStore((state) => state.name)
const email = useStore((state) => state.email)

Mistake 3: Not Handling Async Errors

Always handle errors in async actions. Don't let them fail silently.

Mistake 4: Putting Too Much in One Store

If your store has more than 100 lines, consider splitting it.

Quick Checklist

Before you commit that store code, ask yourself:

  • [ ] Is each store focused on one domain?
  • [ ] Am I using selectors to prevent re-renders?
  • [ ] Are my actions simple and focused?
  • [ ] Am I computing derived state instead of storing it?
  • [ ] Do I have proper loading and error states?
  • [ ] Is the code readable and maintainable?

The Bottom Line

Good Zustand code is:

  • Focused - One store, one responsibility
  • Selective - Subscribe only to what you need
  • Simple - Keep actions small and testable
  • Clean - Organized, typed, and easy to navigate

Start with these patterns. Your future self will thank you.

And when someone new joins your project? They'll actually understand your state management. That's a win.

---

Questions about these patterns? Drop them in the comments. Happy to help!
Get More Like This

Want articles like this in your inbox?

Join developers and founders who get practical insights on frontend, SaaS, and building better products.

S

Written by Salman Izhar

Frontend Developer specializing in React, Next.js, and building high-converting web applications.

Learn More