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.
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.
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.
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.
zustand store, selectors, actions, devtools middleware, subscribe