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.
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:
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:
courses, count, or themecreate(). It holds your state and your actionsaddCourse or toggleThemestate => state.coursesThe 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.
Zustand follows a simpler version of the Flux pattern:
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.
npm install zustandimport { 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;create() -> this builds the store hook and connects it to your React componentscourses -> this is the starting value that the store gives outset() -> this updates the state safely (without breaking old data) and tells all subscribed components about the changeMost 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.
setset 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 itHere’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], })); },}));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);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.
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.
| Storage | Lifetime | Best for | Not ideal for |
|---|---|---|---|
| localStorage | Stays saved until it’s cleared | Theme, login UI flags, saved filters | Sensitive secrets, short-lived state |
| sessionStorage | Gets cleared when the tab closes | Steps in a wizard, temporary drafts, short-term UI state | Data that must stay saved after a browser restart |
partialize so you don’t end up saving large or sensitive data by mistakeversion and migrate whenever the shape of your store changes over timeconst 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:
| Feature | Zustand | Context API | Redux Toolkit |
|---|---|---|---|
| Setup Needed | Very Low | Low | Medium |
| Performance | High | Medium | High |
| How Easy to Learn | Easy | Easy | Medium |
| Best Used For | Medium-sized apps | Small apps | Large, complex apps |
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.