Skip to content

Zustand

Zustand is a small, fast, and scalable state management library for React.
It provides a central store (single source of truth) where shared data, update logic, and derived behavior can live together.


Minimal Boilerplate

No reducers, action types, provider nesting, or heavy setup just to store a few values.

Hook-Based API

Everything works using React hooks, so reading and updating state feels like normal React code.

Selective Re-rendering

Components only re-render when the selected slice changes, not when unrelated store values change.

Production Ready

Works well in real-world apps with middleware for persistence, logging, and DevTools.


State management means tracking the current truth of your application data.

Example idea:

  • A bottle has 1L water
  • Different people see different amounts at different times
  • Without tracking → confusion

Same happens in apps: Different components may show different values.


These terms show up throughout Zustand docs and code:

  • State: the actual data in the store, such as courses, count, or theme
  • Store: the object created by create() that holds state and actions
  • Action: a function inside the store that updates state, such as addCourse or toggleTheme
  • Selector: a function that picks only the state a component needs, such as state => state.courses
  • Subscription: the link between a component and a selected slice of the store
  • Middleware: an enhancement layer that adds features like persistence or DevTools
  • Hydration: loading persisted state back into the store after refresh

The important production idea is simple: store only shared app state here, not every local input value.


graph TD Store["Single Store (Truth)"] A["Component A"] --> Store B["Component B"] --> Store C["Component C"] --> Store Store --> A Store --> B Store --> C

Zustand follows a simplified Flux pattern:

  • Store → holds the source of truth
  • Actions → modify data in a controlled way
  • Components → read state with selectors and trigger actions

You do not dispatch action objects or write reducers for normal Zustand usage. You call a function directly, and that function updates the store.


graph LR UI["User Input"] --> Component Component --> Action["Store Action"] Action --> Store Store --> Updated["Updated State"] Updated --> UI

Terminal window
npm install zustand

import { create } from "zustand";
const useCourseStore = create((set) => ({
courses: [],
addCourse: (course) =>
set((state) => ({
courses: [course, ...state.courses],
})),
removeCourse: (id) =>
set((state) => ({
courses: state.courses.filter((c) => c.id !== id),
})),
toggleCourseStatus: (id) =>
set((state) => ({
courses: state.courses.map((course) => (course.id === id ? { ...course, completed: !course.completed } : course)),
})),
}));
export default useCourseStore;

  1. create() → creates the store hook and wires React components to the store
  2. courses → the initial state value exposed by the store
  3. Actions → functions that describe how state can change
  4. set() → updates state immutably and notifies subscribed components
  5. Components → call the hook with selectors to read only what they need

set() is usually enough for updates. Use get() only when you need to read the current store value inside an action before deciding what to write.


set is used to update state.

set((state) => ({
courses: [...state.courses, newCourse],
}));
  • state → the latest snapshot of the store at the moment of the update
  • Return new object → Zustand merges that into the store
  • Always create new arrays/objects instead of mutating old ones
  • Prefer small focused updates so components subscribed to unrelated data do not re-render

Example with get() when you need the current value directly:

const useCourseStore = create((set, get) => ({
courses: [],
addCourseIfMissing: (course) => {
const exists = get().courses.some((item) => item.id === course.id);
if (exists) return;
set((state) => ({
courses: [course, ...state.courses],
}));
},
}));

graph TD Store["Zustand Store"] State["State (courses)"] Actions["Actions (add/remove/toggle)"] Store --> State Store --> Actions


const courses = useCourseStore((state) => state.courses);

This is the subscription model: the component subscribes only to courses, so it re-renders when courses changes.


const addCourse = useCourseStore((state) => state.addCourse);

This is better than pulling the entire store into the component, because the component does not care about every other field.


const addCourse = useCourseStore((state) => state.addCourse);
  • Component subscribes only to selected part
  • Avoids unnecessary re-renders
  • Makes dependencies explicit and easier to reason about in production code
const store = useCourseStore();

This subscribes the component to the entire store. Any state change can re-render it, even if the component only uses one field.

const { courses, addCourse } = useCourseStore();

This is also broad because it reads the whole store. It looks convenient, but it often defeats Zustand’s biggest performance benefit.

const courses = useCourseStore((state) => state.courses);
const addCourse = useCourseStore((state) => state.addCourse);

For multiple fields, use one selector with a shallow comparison helper when available, or split selectors if the component is small.


function CourseForm() {
const [title, setTitle] = useState("");
const addCourse = useCourseStore((state) => state.addCourse);
function handleSubmit() {
if (!title.trim()) return;
addCourse({
id: Math.ceil(Math.random() * 1000000),
title,
completed: false,
});
setTitle("");
}
return (
<>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button onClick={handleSubmit}>Add</button>
</>
);
}

const { courses, removeCourse, toggleCourseStatus } = useCourseStore((state) => ({
courses: state.courses,
removeCourse: state.removeCourse,
toggleCourseStatus: state.toggleCourseStatus,
}));

This pattern is fine when you need several fields together. In larger components, prefer separate selectors or shallow comparison to keep rerenders predictable.


graph LR Input --> Component Component --> Action Action --> Store Store --> Component

Zustand supports middleware for advanced features.

Middleware wraps the store and adds behavior without changing how components use the hook.


import { createJSONStorage, persist } from "zustand/middleware";
const useStore = create(
persist(
(set) => ({
courses: [],
}),
{
name: "courses",
storage: createJSONStorage(() => localStorage),
},
),
);

Use localStorage when the data should survive browser restarts, such as theme, preferences, and saved drafts.

import { createJSONStorage, persist } from "zustand/middleware";
const useStore = create(
persist(
(set) => ({
courses: [],
}),
{
name: "courses",
storage: createJSONStorage(() => sessionStorage),
},
),
);

Use sessionStorage when the data should disappear when the tab closes, such as temporary form progress or one-session flows.

StorageLifetimeBest forNot ideal for
localStoragePersists until clearedTheme, auth UI flags, persisted filtersSensitive secrets, short-lived state
sessionStorageClears when the tab closesWizard steps, ephemeral drafts, temporary UI stateData that must survive browser restarts
  • Persist only the slices that actually need it
  • Use partialize to avoid storing large or sensitive data
  • Use version and migrate when the store shape changes
  • Keep server data and client cache separate so hydration does not overwrite fresh API responses
const useStore = create(
persist(
(set) => ({
theme: "light",
courses: [],
}),
{
name: "app-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme }),
version: 1,
},
),
);

import { devtools } from "zustand/middleware";
const useStore = create(
devtools((set) => ({
count: 0,
increase: () => set((s) => ({ count: s.count + 1 })),
})),
);

The devtools middleware connects your store to the Redux DevTools browser extension. In Chrome, install the Redux DevTools extension, then open the browser DevTools to inspect Zustand state, action names, and time-travel updates.

Why it matters in production:

  • You can see every store update as an action
  • You can inspect the previous and next state quickly
  • You can time-travel through state changes while debugging
  • Do not leave verbose debug logging in production builds
  • Do not rely on DevTools as a replacement for tests
  • Do not ship sensitive data into the store just because DevTools can inspect it

FeatureZustandContext APIRedux Toolkit
BoilerplateVery LowLowMedium
PerformanceHighMediumHigh
Learning CurveEasyEasyMedium
Best UseMedium appsSmall appsLarge complex apps

  • No provider wrapping needed
  • Fine-grained subscriptions
  • Better performance (less re-renders)
  • Easier to colocate state logic with the store instead of spreading it across reducers and providers

  • Putting everything in one store instead of splitting by domain
  • Selecting entire state instead of the smallest useful slice
  • Destructuring the whole store in components that only need one field
  • Mutating state directly instead of returning new objects and arrays
  • Using random IDs in production when a stable backend ID exists
  • Persisting sensitive or server-owned data in localStorage without a clear reason
  • Forgetting versioning or migration logic when persisted state changes shape
  • Create one store per clear domain, such as auth, courses, or UI preferences
  • Keep actions small and explicit
  • Derive values in selectors when possible instead of storing duplicate state
  • Use stable IDs from your backend or a predictable generator when data must survive reloads

graph TD Store["src/stores/courseStore.js"] Form["CourseForm.jsx"] List["CourseList.jsx"] Selectors["Selectors / derived values"] Form --> Store List --> Store Selectors --> Store

For larger apps, keep store files focused on domain logic and keep components focused on rendering. That separation makes Zustand easier to maintain than a giant catch-all state file.