Zustand Basics

Difficulty: Intermediate

Zustand is a small, fast, and scalable state management library for React. Unlike Context, it lives outside the React tree and uses a pub-sub model, so only components that select specific slices of state re-render when those slices change. There is no Provider wrapper needed - you import the hook and use it directly.

You create a store by calling the 'create' function with a callback that receives 'set' and 'get'. 'set' merges partial state into the store (like Object.assign), and 'get' reads current state synchronously. Actions are plain functions defined inside the store that call 'set' - no action types, reducers, or boilerplate.

Selectors are the key to performance. When you call useStore(state => state.count), the component only re-renders when 'count' changes, not when other parts of the store change. Without a selector (useStore()), the component subscribes to the entire store and re-renders on every change - avoid this.

Zustand provides middleware out of the box: 'devtools' connects your store to the Redux DevTools browser extension for time-travel debugging; 'persist' saves and restores state from localStorage or sessionStorage; 'immer' lets you write mutable-looking updates that produce immutable state under the hood.

Compared to Redux, Zustand has far less boilerplate. Compared to Context, it has built-in selector support so you avoid the all-consumers-re-render problem. It is an excellent default choice for most React applications that need shared state beyond simple prop passing.

Code examples

Creating a basic Zustand store

import { create } from 'zustand';

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

// In a component:
function Counter() {
  const count = useCounterStore((s) => s.count);
  const increment = useCounterStore((s) => s.increment);

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

create() returns a React hook. Selectors (s => s.count) ensure Counter only re-renders when count changes, not when unrelated store fields change.

Zustand with devtools and persist middleware

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

const useAuthStore = create(
  devtools(
    persist(
      (set) => ({
        user: null,
        token: null,
        login: (user, token) => set({ user, token }, false, 'auth/login'),
        logout: () => set({ user: null, token: null }, false, 'auth/logout'),
      }),
      { name: 'auth-storage' }
    ),
    { name: 'AuthStore' }
  )
);

// State persists across page reloads via localStorage
// Actions appear in Redux DevTools with labels 'auth/login', 'auth/logout'

devtools wraps the store so every set() call shows in Redux DevTools. persist automatically saves to localStorage under key 'auth-storage' and rehydrates on mount.

Subscribing outside React

const useStore = create((set) => ({
  notifications: [],
  add: (msg) => set((s) => ({ notifications: [...s.notifications, msg] })),
  clear: () => set({ notifications: [] }),
}));

// Subscribe outside any component
const unsub = useStore.subscribe(
  (state) => console.log('Notifications:', state.notifications.length)
);

// Trigger from anywhere
useStore.getState().add('Hello');
useStore.getState().add('World');
unsub(); // stop listening

useStore.subscribe lets non-React code react to state changes. useStore.getState() gives synchronous access to current state without hooks.

Key points

Concepts covered

zustand store, selectors, actions, devtools middleware, subscribe