TK
All writing
Frontend

Zustand: a beginner's guide to React state without the boilerplate

A practical introduction to Zustand — the tiny, unopinionated state manager for React. Creating a store, reading and updating state, selectors to avoid re-renders, async actions, persistence, and when to reach for it over Context or Redux.

Tanveer Khan9 min read

Most React state-management pain is self-inflicted: a Context that re-renders half the tree, or a Redux setup with three files per feature. Zustand is the antidote — a tiny (~1KB) store you create with a hook, no providers, no boilerplate. This is the practical introduction I give anyone picking it up.

Create a store

A store is a hook. You call create with a function that receives set and returns your state plus the actions that change it.

store.ts
ts
import { create } from "zustand";

type CounterState = {
  count: number;
  increment: () => void;
  reset: () => void;
};

export const useCounter = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
  reset: () => set({ count: 0 }),
}));

Use it in a component

No provider needed — just call the hook anywhere.

Counter.tsx
tsx
function Counter() {
  const count = useCounter((s) => s.count);
  const increment = useCounter((s) => s.increment);
  return <button onClick={increment}>Count: {count}</button>;
}

Always select the SMALLEST slice you need with a selector — useCounter((s) => s.count) — not the whole store. A component only re-renders when its selected value changes, which is how Zustand stays fast.

The re-render trap

selectors.tsx
tsx
// ❌ re-renders on ANY store change — you grabbed everything
const { count } = useCounter();

// ✅ re-renders only when count changes
const count = useCounter((s) => s.count);

Selecting multiple values? Return them and tell Zustand to compare shallowly, or select each separately — otherwise a new object every render defeats the optimisation.

shallow.tsx
tsx
import { useShallow } from "zustand/react/shallow";

const { count, increment } = useCounter(
  useShallow((s) => ({ count: s.count, increment: s.increment })),
);

Async actions

Actions can be async — set works the same before and after the await.

user-store.ts
ts
type UserState = {
  user: User | null;
  loading: boolean;
  fetchUser: (id: string) => Promise<void>;
};

export const useUser = create<UserState>((set) => ({
  user: null,
  loading: false,
  fetchUser: async (id) => {
    set({ loading: true });
    const user = await api.getUser(id);
    set({ user, loading: false });
  },
}));

Persist to localStorage

The persist middleware saves and rehydrates your store with no extra wiring.

persist.ts
ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useSettings = create(
  persist<{ theme: string; setTheme: (t: string) => void }>(
    (set) => ({
      theme: "dark",
      setTheme: (theme) => set({ theme }),
    }),
    { name: "settings" }, // localStorage key
  ),
);

When to use Zustand (and when not to)

  • Reach for it when state is shared across distant components and Context would re-render too much, or Redux is overkill.
  • Keep SERVER data (fetched from an API) in a data-fetching library like TanStack Query — Zustand is for CLIENT state (UI, session, preferences), not a cache.
  • For purely local state, useState is still right — don't put everything in a global store.

Zustand is unopinionated on purpose. That freedom is its strength and its risk: establish conventions early (one store per domain, actions colocated with state, selectors everywhere) so a growing app stays tidy.

Zustand gives you global state that's as simple as useState but shared, without providers or ceremony. Create a store, select the slice you need, keep server data elsewhere — and most of your state-management complexity disappears.