Every Redux project I've touched had a folder you were afraid to open. Actions, reducers, action creators, a types.ts full of string constants, a store.ts that wired it all together, and a Provider strangling the top of the component tree. For a global counter and a theme toggle. The ceremony-to-value ratio was so bad that I started reaching for prop-drilling out of spite. Zustand is the first global store that made me stop dreading that folder, because there is no folder. There's a file. Usually a small one.
I value tools that get out of the way, the same reason my stack is mostly things I can hold in my head at once. Zustand earns its place in that rotation by being almost embarrassingly small while still doing the part that actually matters.
A store is a hook, and that's it
You create a store by calling create. What you get back is a hook. No Provider, no context wiring, no decision about where in the tree to mount anything. You import the hook and use it.
import { create } from "zustand";
type SidebarState = {
open: boolean;
toggle: () => void;
close: () => void;
};
export const useSidebar = create<SidebarState>((set) => ({
open: false,
toggle: () => set((s) => ({ open: !s.open })),
close: () => set({ open: false }),
}));
Then anywhere, any component, at any depth, with no ancestor setup at all:
function MenuButton() {
const toggle = useSidebar((s) => s.toggle);
return <button onClick={toggle}>Menu</button>;
}
That's the entire model. State and the functions that change it live together in one object. Reading it is a hook call. There's no dispatch, no action type matched against a reducer switch, no middleware you have to install just to do an async thing. The absence is the feature. I keep waiting to hit the wall where the simplicity falls apart, and for the kind of client state I actually have, I mostly don't.
One trap is worth predicting before I explain it:
const { open, close } = useStore(
(s) => ({ open: s.open, close: s.close })
);Selectors, and the render trap you have to know about
The one thing you do have to learn is how to subscribe correctly, because getting it wrong quietly tanks your render performance. When you read from the store, pass a selector, a function that picks out exactly the slice you care about:
const open = useSidebar((s) => s.open);
A component subscribed this way only re-renders when open changes. If you instead grab the whole store object, you re-render on every change to any field in it. With a single selector returning a primitive, you're fine. The trap is reaching for multiple values at once and returning a fresh object each render, that new object fails the default equality check every time, so the component re-renders constantly. The fix is to either take one primitive per selector call, or use the useShallow helper so the object is compared field by field instead of by reference.
import { useShallow } from "zustand/shallow";
const { open, close } = useSidebar(
useShallow((s) => ({ open: s.open, close: s.close })),
);
It's a small rule, but it's the one piece of Zustand you can't skip. Learn the selector discipline and the library is nearly invisible. Ignore it and you'll wonder why everything re-renders.
The cost of skipping selectors scales straight with how many components read the store, so it helps to see the spread laid out:
Drag that slider up and the gap between the two rows is the whole argument, the more readers a store gains the more a missing selector hurts.
Why selectors keep the store cheap
The thing that took me a while to internalize is what a selector actually buys you. When you read from a Zustand store, you are subscribing to it, and the selector is the precise statement of what you care about. Subscribe to the whole store object and you have told Zustand that any change anywhere is relevant to you, so every component that did this re-renders on every set, even fields it never touches. That is the same re-render-everything behavior that pushes me off Context in the first place, just imported into a tool that did not have to have it.
A tight selector flips that. useSidebar((s) => s.open) subscribes to one primitive, and Zustand only re-renders that component when that primitive changes by reference equality. Ten components, ten narrow selectors, and a set that touches one field wakes up exactly one of them. The one-line fix for the whole class of problem is to never return the bare s, return the slice. When you genuinely need several fields, reach for useShallow so the returned object is compared field by field instead of by reference, and the subscription stays just as tight.
When I onboard teammates at BMW to this pattern, the fastest way I have found to make it click is to read one real store top to bottom. Here is a small cart store with the setter, the actions, and the selector all walked through line by line.
const useCart = create((set) => ({
items: [],
add: (item) =>
set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));
const count = useCart((s) => s.items.length);No provider, no context, no reducer boilerplate. The function you pass receives set and returns the initial state plus the actions that mutate it. The return value is a hook you call anywhere.
Reading a store this way turns the rules into something you can see rather than memorize. The setter, the colocated actions, and the narrow selector are the same three ideas every Zustand file repeats.
The whole pitch lands faster side by side, the same sidebar toggle, Redux ceremony versus the Zustand version:
// store/sidebarSlice.ts
import { createSlice } from "@reduxjs/toolkit";
const sidebar = createSlice({
name: "sidebar",
initialState: { open: false },
reducers: {
toggle: (s) => { s.open = !s.open; },
close: (s) => { s.open = false; },
},
});
export const { toggle, close } = sidebar.actions;
export default sidebar.reducer;
// store/index.ts, configureStore + <Provider>
// MenuButton.tsx, useDispatch() then dispatch(toggle())No slice file, no configureStore, no Provider, no useDispatch. One file, one hook.
When I reach for it, and when I absolutely don't
Most state doesn't belong in a global store at all, and Zustand being easy doesn't change that. My default is still local: if a value is only used by one component and its children, useState is the answer, full stop. Don't globalize state to avoid passing a prop two levels down.
Context is the next rung. For a value that's genuinely tree-wide and rarely changes, theme, locale, the current user, React Context is fine on its own and brings nothing new to learn. I reach past it for Zustand when the state changes often and is read in scattered, unrelated places, because that's exactly the case where Context's re-render-everything behavior starts to hurt and Zustand's per-selector subscriptions pay off. A sidebar, a command palette, an in-progress multi-step form, a toast queue, UI state that several distant components poke at. That's the sweet spot.
And here's the opinion I'll plant a flag on: don't put server state in a global store. The data that came from your database is not client state. It has a cache lifetime, it needs refetching and invalidation, it can go stale, it has loading and error conditions. Stuffing API responses into Zustand and hand-rolling all of that is rebuilding a worse version of a tool that already exists. Use a real server-state library for server state. Let Zustand own the things that are genuinely client-only, the toggles and the ephemeral UI bits. Keeping that line clean is most of what makes a Zustand codebase stay pleasant.
State lives in one of three places, and picking the right rung is most of the skill. Switch between them:
My default. If a value is only used by one component and its children, useState is the answer, full stop.
Do not globalize state to avoid passing a prop two levels down. Most state never belongs in a global store at all, and Zustand being easy does not change that.
Keeping the store honest as it grows
The failure mode nobody warns you about isn't Zustand being too small, it's a store that quietly turns into a junk drawer. Because adding a field is so cheap, you keep adding, and six months later one store owns the sidebar, three modals, a toast queue, and a half-remembered feature flag. The library won't stop you. The discipline has to come from you, and a few habits have kept mine from rotting.
The first is to colocate the actions with the state they mutate, always. A store where the booleans live in one place and the setters live somewhere else is a store you have to read twice. I keep every piece of state next to the functions that change it, so the store reads like a little state machine: here is the data, here is exactly how it's allowed to move. When that object gets uncomfortable to look at, that discomfort is the signal to split, not to scroll past.
The second is to split by domain into separate stores rather than growing one omniscient store. There is no rule that you get one useStore. I'd rather have useSidebar, useCommandPalette, and useToasts as three small hooks than one useUI that knows everything, because the small ones are independently readable and a component only subscribes to the slice it actually touches. Distinct concerns get distinct stores; the per-selector subscription model means there's no cost to that separation and a real cost to merging them.
The third is to derive, never duplicate. The moment I'm tempted to store a value that's computable from another value, a count next to the array it counts, an isEmpty next to the list, I stop and compute it in the selector instead. Duplicated state is two sources of truth that will disagree the first time you forget to update one, and Zustand makes derived selectors so cheap that there's no excuse. If persist is in play I reach for partialize to keep the derived and ephemeral fields out of localStorage entirely, so a reload rehydrates only the things that were ever meant to outlive it.
None of these are Zustand features. They're the editorial judgment the library deliberately leaves to you, and that hands-off stance is exactly why I trust it, it never pretends to make my architecture decisions for me, it just refuses to charge me ceremony for them.
Testing a store without mounting the app
The payoff I appreciate most only shows up when I write tests. A Zustand store is just a hook wrapped around a plain store object, and that object exposes getState and setState directly. So I can test the state logic on its own: call an action, read the state back, assert. No render, no Provider, no test renderer, no act warnings. The logic that decides how my UI is allowed to move is reachable as plain function calls, which is exactly what the no-ceremony design buys you. Nothing is hidden behind a component boundary, so nothing needs a component to exercise it.
That distinction matters more than it sounds. When I render a component to check that a toggle works, I'm really testing three things at once: the store, the selector wiring, and the markup, and any one of them failing reads as the same red. Testing the store in isolation pins the failure to one place. It's also just faster. Across the roughly 1,280 tests I run with Bun on adatepe.dev, the ones that touch a store directly are the cheap ones, because there's no DOM to spin up. I still write a few render-level tests where the binding itself is the thing I want to verify, but the bulk of the state logic gets covered without any of that.
The one habit this requires is resetting state between tests, since the store is a module-level singleton and will happily carry yesterday's values into your next test. I capture the initial state once and restore it in a beforeEach:
import { useSidebar } from "./sidebar";
const initial = useSidebar.getState();
beforeEach(() => {
useSidebar.setState(initial, true);
});
test("open then close", () => {
useSidebar.getState().open();
expect(useSidebar.getState().isOpen).toBe(true);
useSidebar.getState().close();
expect(useSidebar.getState().isOpen).toBe(false);
});
The true second argument replaces the state rather than merging, so nothing leaks across the boundary. One small line, and every test starts from a known floor.
What this buys you shows up the moment you run the suite, where store tests finish in milliseconds because there is no DOM to mount. Here is roughly what that looks like on my machine.
Forty-one milliseconds for eighteen store tests is the kind of feedback loop that makes you actually write them, and none of it needed a component to exercise the logic.
The middleware I actually use
When I do want a piece of UI state to survive a reload, persist handles it without ceremony, true to form:
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const usePrefs = create(
persist<{ compact: boolean; setCompact: (v: boolean) => void }>(
(set) => ({
compact: false,
setCompact: (v) => set({ compact: v }),
}),
{ name: "ui-prefs" },
),
);
It serializes to localStorage by default and rehydrates on load. One wrapper, no glue code. That restraint is the whole personality of the library.
The gotcha that bit me, and it bites everyone once, is hydration timing in a server-rendered app. The server has no localStorage, so it renders with the default state, and then the client rehydrates from storage on mount. If your first paint reads the persisted value directly, the server HTML and the first client render disagree and React throws a hydration mismatch in the console. I hit this on the theme toggle on adatepe.dev before I understood the order of operations: the server painted compact: false, the client read compact: true from storage, and the two trees did not line up. The fix is to gate on the rehydration flag. persist exposes usePrefs.persist.hasHydrated() and an onRehydrateStorage callback, so I render the default until hydration finishes, then flip to the stored value on the next paint. It costs one extra render but it kills the mismatch entirely, and it is the same defensive pattern I lean on when building AI features in the App Router where the server and client boundaries are easy to blur.
The second persist lesson is to never persist your whole store by reflex. localStorage is a couple of megabytes of synchronous, blocking storage, and a derived count or an ephemeral isOpen has no business surviving a reload. I reach for partialize to whitelist exactly the fields that were ever meant to outlive the session, usually one or two booleans, and let everything else start fresh. A store that persists less is a store that rehydrates faster and drifts less, and that line stays cheap to hold because the middleware never makes the decision for me.
I won't dress this up with numbers, I haven't benchmarked Zustand against anything and I'm not going to invent a figure to sound rigorous. What I can say is that it's the global store that stopped feeling like a tax, and for the small, opinionated stack I keep, that's exactly the trade I want. It's overkill for an app that has no truly shared client state, and it's the wrong tool the moment you point it at server data. Between those two failure modes there's a wide, comfortable middle, and that's where I keep reaching for it.
When I don't reach for Zustand
The honest version of this note is that most of my state never touches Zustand, and I think that's a feature of how I work rather than a limitation. Reaching for a global store is the last decision I make, not the first, and four cases send me elsewhere before I ever call create.
Server state goes in a data layer, not a store. The data that came from my database has a cache lifetime, it refetches, it goes stale, it carries loading and error conditions. Re-implementing all of that by hand inside Zustand is rebuilding a worse copy of a tool that already exists, so I keep a real query layer on top of Drizzle for the data layer and let the store own only the genuinely client-only bits. That line is the single biggest thing keeping my stores pleasant.
URL state goes in the URL. A filter, a tab, a sort order, a page number, anything I'd want to be shareable, bookmarkable, or survive a refresh belongs in the query string, not a store. The router is already a perfectly good piece of global state, and putting it there for free means a pasted link reproduces the exact view. Duplicating it into Zustand just gives me two sources of truth that drift apart.
Truly local state stays in useState. If a value is read by one component and its children and nowhere else, globalizing it to dodge passing a prop two levels down is a net loss in readability. The store should never be a shortcut around prop-drilling.
And Context is fine for rarely-changing tree-wide values: theme, locale, the current user. It brings nothing new to learn and only starts to hurt when the value changes often, because every consumer re-renders. So my decision rule is a short ladder. Local first, then Context for the rare-change tree-wide things, then Zustand only when state changes often and is read in scattered, unrelated places. If a piece of state doesn't clear that last bar, it isn't a Zustand problem. The same restraint shows up everywhere I work, including how I keep a careful state boundary while building AI features in the App Router for projects at BMW and the side work I ship around my M.Sc. CS at LMU Munich.
Before you reach for any global store, run the state you're about to globalize through this. If you can't tick most of it, the answer is useState or Context, not Zustand, and knowing that is half the value:
If you're tired of provider-wrapping and reducer folders for what amounts to a few booleans, give it an afternoon. It fits the same taste that runs through the rest of my work on my /#projects, and I write up more of these tool notes on the /blog as I go.
If the ladder still feels abstract, walk one real piece of state through it. Answer the two questions below and it routes you to the rung I would actually pick, not a generic recommendation.
Should this state go in Zustand?
Run the value you are about to globalize through two quick questions.
Did this value come from your database or an API?
That routing is the whole decision rule in two clicks, and it is the same ladder I run in my head before I ever call create.
Where does your state actually live?
Pick the one that bites you most, I'll point you at what I'd read next.
Whichever state headache you picked, the fix is the same calm, small-store thinking, and if you want to see it holding up under real traffic, here is my work.