Skip to content

Zustand

Zustand is a small, fast, and easy-to-grow state management library for React. It gives you one central store (one single source of truth) where your shared data, the logic to update it, and related behavior can all live together in one place.

Very Little Setup

No reducers, no action types, no wrapping your app in providers. You don’t need heavy setup just to store a few values.

Hook-Based API

Everything works using React hooks. So reading and updating state feels just like normal React code you already know.

Only Updates What's Needed

A component only re-renders when the specific piece of data it picked changes. It does not re-render when some other unrelated value in the store changes.

Ready for Real Apps

It works great in real-world apps too. It has extra add-ons (called middleware) for saving data, logging, and DevTools.

State management simply means keeping track of the current, correct version of your app’s data.

Here’s an easy way to picture it:

  • Imagine a bottle that has 1 liter of water in it
  • If different people look at it at different times, they might think it has different amounts
  • Without proper tracking, this leads to confusion

The same thing can happen in apps. Different components might end up showing different values for the same piece of data.

These words come up again and again in Zustand’s documentation and code, so it helps to know them well:

  • State: the actual data sitting inside the store, like courses, count, or theme
  • Store: the object you create using create(). It holds your state and your actions
  • Action: a function inside the store that changes the state, like addCourse or toggleTheme
  • Selector: a function that picks out only the part of the state a component needs, like state => state.courses
  • Subscription: the connection between a component and the small piece of the store it picked
  • Middleware: an extra layer that adds features like saving data or DevTools
  • Hydration: loading saved data back into the store after the page refreshes

The most important idea for real projects is simple: only store data here that needs to be shared across your app. Don’t store every small local input value here.

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 simpler version of the Flux pattern:

  • Store -> this is where the source of truth lives
  • Actions -> these change the data in a controlled, safe way
  • Components -> these read the state using selectors, and they trigger actions when needed

You don’t need to “dispatch” action objects or write reducers for normal Zustand use. You just call a function directly, and that function takes care of updating the store for you.

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() -> this builds the store hook and connects it to your React components
  2. courses -> this is the starting value that the store gives out
  3. Actions -> these are functions that describe how the state is allowed to change
  4. set() -> this updates the state safely (without breaking old data) and tells all subscribed components about the change
  5. Components -> these call the hook along with a selector, so they only read the small part they actually need

Most of the time, set() is all you need to update things. Only use get() when you need to check the current value in the store before deciding what to write next.

set is the function you use to update the state.

set((state) => ({
courses: [...state.courses, newCourse],
}));
  • state -> this is the latest snapshot of the store at the exact moment you’re updating it
  • When you return a new object -> Zustand merges it into the store for you
  • Always create new arrays or objects. Never change (mutate) the old ones directly
  • Try to make small, focused updates. This way, components that don’t care about this data won’t re-render for no reason

Here’s an example using get() when you need to check the current value first:

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 called the subscription model. The component only subscribes to courses, so it will only re-render when courses actually changes, and nothing else.

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

This is a better way to do it, instead of pulling the whole store into the component. Why? Because the component doesn’t care about every other field, only this one action.

const addCourse = useCourseStore((state) => state.addCourse);
  • The component subscribes only to the part it picked
  • This avoids re-renders that aren’t actually needed
  • It makes it clear, just by reading the code, what each component actually depends on
const store = useCourseStore();

This makes the component subscribe to the entire store. Now, any change anywhere in the store can cause this component to re-render, even if it only uses one small field.

const { courses, addCourse } = useCourseStore();

This is also too broad, because it still reads the whole store behind the scenes. It might look convenient and short, but it usually cancels out Zustand’s biggest advantage: avoiding extra re-renders.

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

If you need more than one field at once, use a single selector along with a shallow comparison helper (if Zustand provides one), or simply split it into separate selectors if your 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 works fine when you need several fields together at once. But for bigger components, it’s better to use separate selectors, or a shallow comparison, so re-renders stay predictable and easy to follow.

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

Zustand supports middleware, which are extra add-ons that give you advanced features.

Middleware basically “wraps around” your store and adds new behavior, without changing how your components use the hook.

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

Use localStorage when you want the data to stay saved even after the browser is closed and reopened. Good examples are theme settings, preferences, and saved drafts.

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

Use sessionStorage when you want the data to disappear once the browser tab is closed. Good examples are temporary form progress or things that only matter for one session.

StorageLifetimeBest forNot ideal for
localStorageStays saved until it’s clearedTheme, login UI flags, saved filtersSensitive secrets, short-lived state
sessionStorageGets cleared when the tab closesSteps in a wizard, temporary drafts, short-term UI stateData that must stay saved after a browser restart

Good Habits for Saving Data in Real Projects

Section titled “Good Habits for Saving Data in Real Projects”
  • Only save the small pieces of data that actually need to be saved
  • Use partialize so you don’t end up saving large or sensitive data by mistake
  • Use version and migrate whenever the shape of your store changes over time
  • Keep server data and client cache (locally stored data) separate. This way, old saved data doesn’t accidentally overwrite fresh data that just came from the API
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 first. Then open your browser’s DevTools to look inside your Zustand state, see action names, and even go back in time through past updates.

Why this is helpful in real projects:

  • You can see every single update to the store, shown as an action
  • You can quickly check the state before and after each change
  • You can “time-travel” through past state changes while you’re debugging
  • Don’t leave detailed debug logs running in your production app
  • Don’t depend on DevTools as a replacement for writing proper tests
  • Don’t put sensitive data in the store just because DevTools happens to be able to view it
FeatureZustandContext APIRedux Toolkit
Setup NeededVery LowLowMedium
PerformanceHighMediumHigh
How Easy to LearnEasyEasyMedium
Best Used ForMedium-sized appsSmall appsLarge, complex apps

Why Zustand Is Better Than Context API (in Many Cases)

Section titled “Why Zustand Is Better Than Context API (in Many Cases)”
  • No need to wrap your app in providers
  • You can subscribe to exactly the small piece of data you need
  • Better performance, since there are fewer unnecessary re-renders
  • It’s easier to keep your state logic together in one place, instead of spreading it out across many reducers and providers
  • Putting everything into one giant store instead of splitting it by topic (like auth, courses, etc.)
  • Selecting the entire state instead of just the small useful piece you actually need
  • Pulling out the whole store in components that only need one single field
  • Directly changing (mutating) the state instead of returning new objects and arrays
  • Using random IDs in a real production app when a proper, stable backend ID already exists
  • Saving sensitive or server-owned data into localStorage without a good reason
  • Forgetting to add versioning or migration logic when the shape of your saved data changes
  • Make one store for each clear topic, like auth, courses, or UI preferences
  • Keep your actions small and clear about what they do
  • When possible, calculate values inside selectors instead of storing duplicate copies of the same data
  • Use stable IDs that come from your backend, or a predictable ID generator, whenever the data needs to survive page 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 bigger apps, it’s best to keep your store files focused only on the logic for that one topic, and keep your components focused only on showing things on screen. This kind of separation makes Zustand much easier to manage, instead of having one giant file that tries to do everything.