React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandsetupstorebasics

Zustand Setup and Basic Usage

Recipe

Install Zustand, create a store with create, and consume it in any component without providers or context wrappers.

npm install zustand
// stores/counter-store.ts
import { create } from "zustand";
 
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}
 
export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));
"use client";
 
import { useCounterStore } from "@/stores/counter-store";
 
function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

Working Example

// stores/todo-store.ts
import { create } from "zustand";
 
interface Todo {
  id: string;
  text: string;
  done: boolean;
}
 
interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  removeTodo: (id: string) => void;
  clearCompleted: () => void;
}
 
export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
 
  addTodo: (text) =>
    set((state) => ({
      todos: [...state.todos, { id: crypto.randomUUID(), text, done: false }],
    })),
 
  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    })),
 
  removeTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((t) => t.id !== id),
    })),
 
  clearCompleted: () =>
    set((state) => ({
      todos: state.todos.filter((t) => !t.done),
    })),
}));
// components/todo-app.tsx
"use client";
 
import { useState } from "react";
import { useTodoStore } from "@/stores/todo-store";
 
export function TodoApp() {
  const [input, setInput] = useState("");
  const todos = useTodoStore((s) => s.todos);
  const addTodo = useTodoStore((s) => s.addTodo);
  const toggleTodo = useTodoStore((s) => s.toggleTodo);
  const removeTodo = useTodoStore((s) => s.removeTodo);
  const clearCompleted = useTodoStore((s) => s.clearCompleted);
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      addTodo(input.trim());
      setInput("");
    }
  };
 
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button type="submit">Add</button>
      </form>
 
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => toggleTodo(todo.id)}
              />
              <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
                {todo.text}
              </span>
            </label>
            <button onClick={() => removeTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
 
      <button onClick={clearCompleted}>Clear completed</button>
      <p>{todos.filter((t) => !t.done).length} items remaining</p>
    </div>
  );
}

Deep Dive

How It Works

  • create returns a React hook that subscribes to the store. The store itself is a vanilla JavaScript object managed outside of React.
  • The set function merges the partial state into the current state (shallow merge by default).
  • Components only re-render when the slice of state they select changes. This is Zustand's key performance advantage over Context.
  • No Provider is needed. The store is a module-level singleton that any component can import and use directly.
  • set can accept an object (merged) or a function (state) => partialState (for updates based on current state).

Variations

Getting full state (not recommended for performance):

const { count, increment } = useCounterStore();
// Re-renders on ANY state change

Using store outside React:

// Access state directly (no hooks)
const count = useCounterStore.getState().count;
 
// Subscribe to changes
const unsub = useCounterStore.subscribe((state) => {
  console.log("Count changed:", state.count);
});

Replace state instead of merge:

set({ count: 0 }, true); // Second arg `true` replaces entire state

TypeScript Notes

  • Always pass the state interface as a generic to create<State>().
  • Actions (functions) live alongside state in the same interface.
  • set is typed to accept Partial<State> or (state: State) => Partial<State>.
import { create, StoreApi } from "zustand";
 
type Store = StoreApi<CounterState>;

Gotchas

  • Selecting the entire store (useStore() with no selector) causes the component to re-render on every state change. Always use selectors.
  • set performs a shallow merge. Nested objects must be spread manually: set({ user: { ...state.user, name: "new" } }).
  • The store is a singleton. In SSR environments (Next.js), a single store is shared across all requests unless you create per-request instances.
  • Do not destructure the store hook at the module level. Call it inside components only.
  • set is synchronous. The state update and re-render happen in the same tick (batched by React 18+).

Alternatives

ApproachProsCons
ZustandNo providers, minimal API, fast selectorsSingleton in SSR, learning curve for middleware
React ContextBuilt-in, no dependenciesRe-renders all consumers, no selectors
Redux ToolkitMature ecosystem, DevToolsBoilerplate, complex setup
JotaiAtomic model, bottom-upDifferent mental model, many atoms to manage

Real-World Example

From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).

// Production example: Auth store
// File: src/stores/auth.ts
import { create } from 'zustand'
import { User } from '@supabase/supabase-js'
 
interface AuthState {
  user: User | null
  loading: boolean
  setUser: (user: User | null) => void
  setLoading: (loading: boolean) => void
}
 
export const useAuthStore = create<AuthState>()((set) => ({
  user: null,
  loading: true,
  setUser: (user) => set({ user }),
  setLoading: (loading) => set({ loading }),
}))
 
// Usage with selector (prevents re-renders from unrelated state changes):
// const user = useAuthStore((state) => state.user)
// NOT: const { user } = useAuthStore()  // subscribes to ALL changes

What this demonstrates in production:

  • The double parentheses create<AuthState>()((set) => ...) is not a typo. The first () is required when using TypeScript generics with Zustand, and it also enables middleware chaining (e.g., create<AuthState>()(persist(devtools((set) => ...)))).
  • set({ user }) does a shallow merge, not a replacement. Only the user field is updated while loading remains untouched. This is Zustand's default behavior.
  • Always use selectors: useAuthStore((s) => s.user) subscribes only to the user field. Using const { user } = useAuthStore() without a selector subscribes to the entire store, causing re-renders whenever any field changes (including loading).
  • The store exists outside React as a module-level singleton. It can be accessed anywhere via useAuthStore.getState(), including in non-React code like API utilities or middleware.
  • In Next.js SSR, this singleton is shared across all requests on the server. For user-specific state, initialize the store on the client side only or use a per-request store pattern.

FAQs

What does the create function return and how do you use it?
  • create returns a React hook (e.g., useCounterStore) that subscribes to the store.
  • Call it with a selector inside a component: useCounterStore((state) => state.count).
  • The store itself is a vanilla JavaScript object managed outside React.
Why do you pass a selector function to the store hook instead of destructuring?
  • Selectors like (state) => state.count subscribe to only that slice of state.
  • The component only re-renders when the selected value changes.
  • Destructuring without a selector (const { count } = useStore()) subscribes to the entire store, causing re-renders on every change.
How does set work -- does it replace or merge state?
  • By default, set performs a shallow merge of the partial state into the current state.
  • To replace the entire state, pass true as the second argument: set({ count: 0 }, true).
  • Nested objects must be spread manually since the merge is only one level deep.
Do you need a Provider or Context wrapper to use Zustand?
  • No. Zustand stores are module-level singletons.
  • Any component can import and use the store hook directly.
  • Providers are only needed in SSR/Next.js to avoid shared state across requests.
How do you access Zustand state outside of a React component?
// Read state directly
const count = useCounterStore.getState().count;
 
// Subscribe to changes
const unsub = useCounterStore.subscribe((state) => {
  console.log("Count:", state.count);
});
What is the difference between set({ count: 0 }) and set((state) => ({ count: state.count + 1 }))?
  • set({ count: 0 }) sets a static value regardless of current state.
  • set((state) => ...) computes the next state based on the current state, which is necessary for increments, toggles, and derived updates.
Gotcha: What happens if you select the entire store with no selector?
  • Calling useStore() with no selector subscribes to every property in the store.
  • The component re-renders on any state change, even unrelated fields.
  • Always use a selector to avoid unnecessary re-renders.
Gotcha: Why does set({ user: { name: "new" } }) not preserve other user fields?
  • set does a shallow merge at the top level only.
  • Nested objects are replaced entirely, not merged.
  • You must spread manually: set((s) => ({ user: { ...s.user, name: "new" } })).
How do you type a Zustand store in TypeScript?
interface CounterState {
  count: number;
  increment: () => void;
}
 
const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((s) => ({ count: s.count + 1 })),
}));
  • Pass the state interface as a generic to create<State>().
  • Actions and state live together in the same interface.
What type does set accept in TypeScript?
  • set accepts Partial<State> or (state: State) => Partial<State>.
  • TypeScript enforces that you only set properties that exist in the state interface.
Why does the production example use double parentheses create<AuthState>()((...) => ...)?
  • The first () is required when using TypeScript generics with middleware.
  • It also enables middleware chaining: create<State>()(persist(devtools((set) => ...))).
  • Without middleware, create<State>((set) => ...) (single invocation) is fine.
Is set synchronous or asynchronous?
  • set is synchronous. The state update happens immediately.
  • React 18+ batches re-renders, so rapid sequential set calls may batch into a single render.