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
createreturns a React hook that subscribes to the store. The store itself is a vanilla JavaScript object managed outside of React.- The
setfunction 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.
setcan 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 changeUsing 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 stateTypeScript Notes
- Always pass the state interface as a generic to
create<State>(). - Actions (functions) live alongside state in the same interface.
setis typed to acceptPartial<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. setperforms 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.
setis synchronous. The state update and re-render happen in the same tick (batched by React 18+).
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Zustand | No providers, minimal API, fast selectors | Singleton in SSR, learning curve for middleware |
| React Context | Built-in, no dependencies | Re-renders all consumers, no selectors |
| Redux Toolkit | Mature ecosystem, DevTools | Boilerplate, complex setup |
| Jotai | Atomic model, bottom-up | Different 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 changesWhat 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 theuserfield is updated whileloadingremains untouched. This is Zustand's default behavior.- Always use selectors:
useAuthStore((s) => s.user)subscribes only to theuserfield. Usingconst { user } = useAuthStore()without a selector subscribes to the entire store, causing re-renders whenever any field changes (includingloading). - 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?
createreturns 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.countsubscribe 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,
setperforms a shallow merge of the partial state into the current state. - To replace the entire state, pass
trueas 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?
setdoes 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?
setacceptsPartial<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?
setis synchronous. The state update happens immediately.- React 18+ batches re-renders, so rapid sequential
setcalls may batch into a single render.