← Back to articles

Zustand: Lightweight State Management That Gets Out of Your Way

· 5 min read

State management in React has a long history of overengineering. Redux brought structure but also boilerplate, action types, reducers, dispatchers, and an entire mental model to learn before writing a single line of business logic. Context API solved the prop-drilling problem but re-renders everything that consumes it, making it a poor fit for frequently changing state. Zustand sits in the sweet spot: a store you can create in five lines, with surgical re-renders, TypeScript inference out of the box, and zero opinions about how you structure your app.

A store in its simplest form

A Zustand store is a hook. You define your state and actions in a single create call, and any component can consume it directly without wrapping the tree in a provider.

stores/counter.ts
import { create } from 'zustand';

interface CounterStore {
  count: number;
  increment: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));
components/Counter.tsx
function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return <button onClick={increment}>{count}</button>;
}

No provider, no context, no dispatch. The component subscribes to exactly the slice of state it needs, and only re-renders when that slice changes. A sibling component that reads reset but not count won't re-render when the count changes.

Selectors and re-render control

The key to Zustand's performance is selectors. Every time you call the hook with a selector function, Zustand compares the previous and next result with strict equality. If nothing changed, the component stays put.

components/UserProfile.tsx
// Only re-renders when `name` changes, not when other store fields update
const name = useUserStore((state) => state.name);

For derived values that return new object references, use useShallow to compare by shallow equality instead.

components/CartSummary.tsx
import { useShallow } from 'zustand/shallow';

const { items, total } = useCartStore(
  useShallow((state) => ({ items: state.items, total: state.total }))
);

This pattern replaces what would be a useMemo + useSelector combo in Redux. One line, no extra hook.

Async actions

Zustand doesn't impose any middleware for async logic. Actions are just functions, so you call set whenever the data is ready.

stores/products.ts
interface ProductStore {
  products: Product[];
  isLoading: boolean;
  fetchProducts: () => Promise<void>;
}

export const useProductStore = create<ProductStore>((set) => ({
  products: [],
  isLoading: false,
  fetchProducts: async () => {
    set({ isLoading: true });
    const products = await api.getProducts();
    set({ products, isLoading: false });
  },
}));

No thunks, no sagas, no extra package. The async function lives in the store alongside synchronous actions, and TypeScript infers everything.

Middleware that composes

Zustand's middleware system is functional composition. Each middleware wraps the store creator, and you stack them as needed.

Persist to localStorage

stores/settings.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

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

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

The store rehydrates from localStorage on mount. You can swap the storage engine to sessionStorage, IndexedDB, or anything async by providing a custom storage option.

Devtools integration

stores/auth.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useAuthStore = create<AuthStore>()(
  devtools(
    (set) => ({
      user: null,
      login: async (credentials) => {
        const user = await api.login(credentials);
        set({ user }, false, 'auth/login');
      },
    }),
    { name: 'AuthStore' }
  )
);

The third argument to set names the action in Redux DevTools. You get time-travel debugging without Redux.

Scaling with slices

As your store grows, you can split it into slices that compose into a single store. Each slice defines its own piece of state and actions.

stores/index.ts
import { create } from 'zustand';

interface UserSlice {
  user: User | null;
  setUser: (user: User) => void;
}

interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
}

type AppStore = UserSlice & CartSlice;

const createUserSlice = (set: any): UserSlice => ({
  user: null,
  setUser: (user) => set({ user }),
});

const createCartSlice = (set: any): CartSlice => ({
  items: [],
  addItem: (item) => set((state: AppStore) => ({ items: [...state.items, item] })),
});

export const useAppStore = create<AppStore>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

Components still pick exactly what they need with selectors. The internal split is invisible to consumers. This pattern scales to large applications without the folder-per-feature ceremony that Redux demands.

When Zustand is the right call

Zustand isn't always the answer. For truly global, complex state with deeply nested updates and undo/redo, Redux Toolkit still has its place. For server state (fetching, caching, revalidation), TanStack Query or SWR are better tools. Zustand shines for client-side application state: UI toggles, form wizards, shopping carts, user preferences, any state that lives in the browser and needs to be shared across components without the overhead of a context provider or the ceremony of Redux.

Conclusion

Zustand proves that state management doesn't have to be complicated. A store is a hook, actions are functions, selectors control re-renders, and middleware composes cleanly. It handles 90% of what teams used to reach for Redux to solve, with a fraction of the code and cognitive load. If your next project needs shared client state, start here.