//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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>
);
}// 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>
);
}create returns a React hook that subscribes to the store. The store itself is a vanilla JavaScript object managed outside of React.set function merges the partial state into the current state (shallow merge by default).set can accept an object (merged) or a function (state) => partialState (for updates based on current state).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 statecreate<State>().set is typed to accept Partial<State> or (state: State) => Partial<State>.import { create, StoreApi } from "zustand";
type Store = StoreApi<CounterState>;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" } }).set is synchronous. The state update and re-render happen in the same tick (batched by React 18+).| 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 |
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:
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.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).useAuthStore.getState(), including in non-React code like API utilities or middleware.create returns a React hook (e.g., useCounterStore) that subscribes to the store.useCounterStore((state) => state.count).(state) => state.count subscribe to only that slice of state.const { count } = useStore()) subscribes to the entire store, causing re-renders on every change.set performs a shallow merge of the partial state into the current state.true as the second argument: set({ count: 0 }, true).// Read state directly
const count = useCounterStore.getState().count;
// Subscribe to changes
const unsub = useCounterStore.subscribe((state) => {
console.log("Count:", state.count);
});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.useStore() with no selector subscribes to every property in the store.set does a shallow merge at the top level only.set((s) => ({ user: { ...s.user, name: "new" } })).interface CounterState {
count: number;
increment: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));create<State>().set accepts Partial<State> or (state: State) => Partial<State>.() is required when using TypeScript generics with middleware.create<State>()(persist(devtools((set) => ...))).create<State>((set) => ...) (single invocation) is fine.set is synchronous. The state update happens immediately.set calls may batch into a single render.Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥