Asher Cohen
Back to posts

Zustand: Lightweight State Management for React

Complete guide to Zustand — store creation, subscriptions, middleware, and comparison with Redux and Context API

Introduction

Zustand is a small, fast, and scalable state management solution for React. Unlike Redux, it requires no providers, no action types, and no reducers. It's built on hooks and works outside React components too — making it one of the most flexible state libraries available.

Why Zustand?

The Problem with Other Solutions

// ❌ Redux - lots of boilerplate
// Actions, reducers, store, Provider, connect/mapStateToProps...

// ❌ React Context - re-renders all consumers
// Every context value change re-renders every consumer

// ✅ Zustand - minimal, performant, flexible
import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 })
}));

// Use in any component
function Counter() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  
  return <button onClick={increment}>{count}</button>;
}

Core Concepts

Creating a Store

import { create } from 'zustand';

// Basic store
const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 })
}));

// Store with get for accessing state without subscription
const useStore = create((set, get) => ({
  count: 0,
  increment: () => set({ count: get().count + 1 }),
  
  // Async action
  fetchCount: async () => {
    const response = await fetch('/api/count');
    const data = await response.json();
    set({ count: data.count });
  }
}));

Selecting State (Performance)

The key to Zustand performance is selective subscriptions:

// ❌ Re-renders on ANY state change
const state = useStore();

// ✅ Only re-renders when count changes
const count = useStore((state) => state.count);

// ✅ Select multiple values with shallow comparison
import { shallow } from 'zustand/shallow';

const { count, increment } = useStore(
  (state) => ({ count: state.count, increment: state.increment }),
  shallow
);

// ✅ Use a selector function
const bears = useStore((state) => state.bears);
const fish = useStore((state) => state.fish);

Updating State

const useStore = create((set) => ({
  user: { name: 'Alice', age: 30 },
  items: [1, 2, 3],
  
  // Replace entire value
  setUser: (user) => set({ user }),
  
  // Merge (shallow)
  updateUser: (updates) => set((state) => ({
    user: { ...state.user, ...updates }
  })),
  
  // Nested update with immer middleware
  incrementAge: () => set((state) => ({
    user: { ...state.user, age: state.user.age + 1 }
  })),
  
  // Array operations
  addItem: (item) => set((state) => ({
    items: [...state.items, item]
  })),
  
  // Replace state entirely
  reset: () => set({ user: { name: '', age: 0 }, items: [] })
}));

Middleware

Immer Middleware

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

const useStore = create(
  immer((set) => ({
    user: {
      name: 'Alice',
      address: { city: 'Paris', zip: '75001' }
    },
    
    // Mutate state directly with Immer
    updateCity: (city) => set((state) => {
      state.user.address.city = city;
    }),
    
    updateZip: (zip) => set((state) => {
      state.user.address.zip = zip;
    })
  }))
);

Persist Middleware

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

const useStore = create(
  persist(
    (set) => ({
      theme: 'dark',
      setTheme: (theme) => set({ theme })
    }),
    {
      name: 'app-settings',     // localStorage key
      partialize: (state) => ({  // Only persist theme
        theme: state.theme
      })
    }
  )
);

DevTools Middleware

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set(
        (state) => ({ count: state.count + 1 }),
        false,
        'increment' // Action name in DevTools
      )
    }),
    { name: 'MyStore' }
  )
);

Combining Middleware

import { create } from 'zustand';
import { devtools, persist, immer } from 'zustand/middleware';

const useStore = create(
  devtools(
    persist(
      immer((set) => ({
        todos: [],
        addTodo: (text) => set((state) => {
          state.todos.push({ text, done: false });
        })
      })),
      { name: 'todos-storage' }
    ),
    { name: 'TodosStore' }
  )
);

Subscription Management

Automatic vs Manual

// Zustand subscriptions are semi-automatic:
// - Components auto-subscribe via hooks
// - API available for manual unsubscription

// Manual subscription (outside React)
const unsubscribe = useStore.subscribe(
  (state) => state.count,
  (count) => {
    console.log('Count changed:', count);
  }
);

// Unsubscribe when done
unsubscribe();

// Subscribe to entire state
const unsubscribe = useStore.subscribe(
  (state) => state,
  (state) => {
    console.log('State changed:', state);
  },
  { equalityFn: shallow }
);

Using Store Outside React

// Zustand stores work outside React components
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }))
}));

// Get state without subscribing
const count = useStore.getState().count;

// Set state directly
useStore.setState({ count: 10 });

// Subscribe to changes
const unsubscribe = useStore.subscribe(console.log);

// In tests, WebSocket handlers, event listeners, etc.
websocket.on('message', (data) => {
  useStore.getState().handleMessage(data);
});

TypeScript Patterns

import { create } from 'zustand';

// Define store interface
interface BearStore {
  bears: number;
  increase: () => void;
  decrease: () => void;
  reset: () => void;
}

const useBearStore = create<BearStore>((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
  decrease: () => set((state) => ({ bears: state.bears - 1 })),
  reset: () => set({ bears: 0 })
}));

// With middleware types
import { persist } from 'zustand/middleware';

interface SettingsStore {
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;
}

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme })
    }),
    { name: 'settings' }
  )
);

Common Patterns

Slice Pattern (Multiple Stores)

// Split state into focused stores
const useBearStore = create((set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 }))
}));

const useFishStore = create((set) => ({
  fish: 0,
  addFish: () => set((state) => ({ fish: state.fish + 1 }))
}));

// Compose in components
function AnimalCounter() {
  const bears = useBearStore((s) => s.bears);
  const fish = useFishStore((s) => s.fish);
  
  return <div>Bears: {bears}, Fish: {fish}</div>;
}

Computed Values

const useStore = create((set, get) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  
  // Computed: derived from state
  getItemCount: () => get().items.length,
  getTotalPrice: () => get().items.reduce((sum, item) => sum + item.price, 0)
}));

// Use in component
function Cart() {
  const items = useStore((s) => s.items);
  const itemCount = useStore((s) => s.getItemCount());
  const totalPrice = useStore((s) => s.getTotalPrice());
  
  return (
    <div>
      <span>{itemCount} items</span>
      <span>Total: ${totalPrice}</span>
    </div>
  );
}

Async Actions

const useStore = create((set, get) => ({
  users: [],
  loading: false,
  error: null,
  
  fetchUsers: async () => {
    set({ loading: true, error: null });
    
    try {
      const response = await fetch('/api/users');
      if (!response.ok) throw new Error('Failed to fetch');
      const users = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
  
  // Optimistic update
  updateUser: async (id, updates) => {
    const previousUsers = get().users;
    
    // Optimistic update
    set((state) => ({
      users: state.users.map(u => u.id === id ? { ...u, ...updates } : u)
    }));
    
    try {
      await fetch(`/api/users/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(updates)
      });
    } catch {
      // Rollback on failure
      set({ users: previousUsers });
    }
  }
}));

Zustand vs Redux vs Context

FeatureZustandRedux ToolkitReact Context
Bundle size~1 KB~11 KB0 KB (built-in)
BoilerplateMinimalLow (with RTK)Minimal
Provider neededNoYesYes
Outside ReactYesYesNo
DevToolsYesYesNo
MiddlewareYesYesNo
PerformanceSelective re-rendersSelective (with selectors)Re-renders all consumers
Learning curveLowMediumLow

Best Practices

Do's

// ✅ Use selective selectors for performance
const count = useStore((s) => s.count);

// ✅ Split into multiple small stores
const useUserStore = create(/* ... */);
const useCartStore = create(/* ... */);

// ✅ Use middleware for cross-cutting concerns
// persist, devtools, immer

// ✅ Access state outside React when needed
useStore.getState().someValue;

// ✅ Use shallow comparison for multiple values
import { shallow } from 'zustand/shallow';
const { a, b } = useStore(selector, shallow);

Don'ts

// ❌ Don't select the entire store
const state = useStore(); // Re-renders on every change

// ❌ Don't create stores inside components
function Component() {
  const store = create(/* ... */); // New store every render!
}

// ❌ Don't mutate state outside set()
const state = useStore.getState();
state.count = 5; // Won't trigger re-renders

// ❌ Don't forget to unsubscribe from manual subscriptions
const unsub = useStore.subscribe(/* ... */);
// Always call unsub() when done

Summary Table

APIPurposeExample
create(fn)Create storecreate((set) => ({...}))
set(state)Update stateset({ count: 1 })
set(fn)Functional updateset((s) => ({ count: s.count + 1 }))
get()Get current stateget().count
useStore(sel)Subscribe in componentuseStore((s) => s.count)
useStore.getState()Get state outside ReactuseStore.getState()
useStore.setState()Set state outside ReactuseStore.setState({...})
useStore.subscribe()Manual subscriptionuseStore.subscribe(fn)

Conclusion

Zustand is the lightweight state management solution that gets out of your way:

  • No providers — use stores directly in any component
  • No boilerplate — just create() and set()
  • Works everywhere — inside and outside React
  • Tiny bundle — ~1 KB gzipped
  • TypeScript-first — excellent type inference
  • Flexible — middleware for persistence, devtools, immer

Choose Zustand when you want simple, performant state management without the ceremony of Redux or the re-render issues of Context.

#zustand #react #state-management #javascript #typescript #web-development