Most apps reach for Redux and instantly regret it. Context API leaks state everywhere. Jotai is elegant but overkill for 90% of projects. Zustand is the modern default: 1KB bundle, zero providers, no boilerplate. Define a store in 5 lines, subscribe from anywhere, use selectors for performance, plug in persist middleware. This is how production state management works in 2026.
State management libraries have a terrible reputation because most of them solve a problem that doesn't exist anymore. Redux was built to make state predictable in 2015 when React had no Hooks and components were deep. You'd create a store, wire it through context, dispatch actions, handle reducers, and write middleware. The boilerplate was immense, but so was the clarity — every state mutation was traceable. In 2025, that overhead is cargo-cult. Zustand flips the model: stores are just plain JavaScript objects with update functions, no actions, no reducers, no middleware layers (unless you want them). Define your data, define how to mutate it, and call the mutation directly. That's it. No provider hell, no global context setup, no TypeScript gymnastics. This is why Aidxn ships Zustand everywhere, and why Redux is the right choice for approximately zero apps.
The Problem: State Management Overhead
Redux has earned its reputation. A feature request for "track the user's cart" becomes: define an action type (CART_ADD_ITEM), write an action creator (cart.add = (item) => ({ type: CART_ADD_ITEM, payload: item })), write a reducer case that handles the action, wire the store through context, subscribe components to the selector you created for that slice, and now dispatch from your component. That's 30 lines of boilerplate to mutate an object. For a 2000-line app, you've got 200 actions, 200 action creators, and hundreds of reducer cases scattered across files. Every engineer interprets "the Redux way" differently — some normalize all nested data, others don't, some use redux-thunk, others use redux-saga. The library is flexible, and flexibility breeds inconsistency. By the time you hire a third engineer, they've invented their own Redux patterns.
Context API is "the React solution," and it's a trap. Context was designed for theming and global UI state, not data management. Use Context for your app's 50 pieces of global data (dark mode, user, locale), and you'll create 50 separate contexts, wire 50 providers into your root component, and face the "provider hell" where 30 lines at the top of your tree are just providers. Update one piece of context, and the entire tree beneath it rerenders, even if only one component cared about that update. Context scales backward — it works for small apps and gets slower as you add more state. For Rebuild Relief's location-sorting dashboard with hundreds of local mutations per second, Context becomes a bottleneck.
Jotai is elegant — atoms are composable, re-render optimization is automatic, and the mental model is functional. But it's still overkill. Jotai shines for fine-grained reactivity (physics engines, spreadsheets), not business apps. For a dashboard with 5 stores (user, routes, jobs, filters, UI state), Zustand is simpler and faster.
Zustand is different because it's not trying to be a framework. It's a tiny library that gives you one thing: a subscriptable state object that runs callbacks when you mutate it. No philosophy, no layers, no constraints. You own the state. You own the updates. React components subscribe to the parts they care about, and only those parts trigger rerenders. Done.
The Pattern: A Store in 5 Lines
Here's a Zustand store for a user dashboard:
// src/stores/userStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
role: 'admin' | 'user';
}
interface UserStore {
user: User | null;
setUser: (user: User | null) => void;
logout: () => void;
}
export const useUserStore = create<UserStore>(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}),
{ name: 'user-store' },
),
);
That's it. No actions, no reducers, no thunk middleware, no provider. The `create` function takes a callback — `set` lets you update state, and you return an object with your state and update functions. The `persist` middleware saves to localStorage automatically. Now subscribe from any component:
// src/components/UserProfile.tsx
import { useUserStore } from '@/stores/userStore';
export function UserProfile() {
const { user, logout } = useUserStore();
if (!user) return <div>Not logged in</div>;
return (
<div>
<p>Welcome, {user.email}</p>
<button onClick={logout}>Log out</button>
</div>
);
}
The component subscribes only to what it uses (`user` and `logout`). If another component updates a different part of the store, this component won't rerender. No context provider. No hook dependency arrays. No re-render spikes. This is how state should feel.
Why This Stack Beats The Alternatives
vs. Redux: Redux is 43K minified. Zustand is 1.2K. For a Redux store, you write actions, reducers, selectors, and wire them through thunk or saga middleware. For the same feature in Zustand, you write one store file. Redux is useful for state time-travel debugging and strict action logging. For 90% of apps, that's not the problem you're solving. You're solving "manage data and update it," and Redux is an unnecessarily heavy tool for that.
vs. Context API: Context is a re-render bottleneck because updating one value triggers a rerender of every component that subscribed to that context. Zustand uses shallow equality by default — update one field, only components that watch that field rerender. Plus, Context requires provider nesting; Zustand requires nothing. No setup at the root, no prop drilling, no passing down 12 nested providers.
vs. Jotai: Jotai is more powerful — atoms are composable, derived atoms are elegant, async atoms handle API data smoothly. But Jotai adds mental overhead (which atoms depend on which, when do derivations recompute). For a CRUD dashboard with 5 stores and no heavy async orchestration, Zustand is simpler and equally fast.
Four Essential Patterns
Pattern 1: Store Slicing for Clarity
Split a large app into multiple stores instead of one giant store. Each feature (user, routes, notifications) gets its own file.
// src/stores/index.ts
export { useUserStore } from './userStore';
export { useRoutesStore } from './routesStore';
export { useFiltersStore } from './filtersStore';
// src/stores/routesStore.ts
export const useRoutesStore = create<RoutesStore>((set) => ({
routes: [],
addRoute: (route) => set((state) => ({ routes: [...state.routes, route] })),
deleteRoute: (id) => set((state) => ({ routes: state.routes.filter(r => r.id !== id) })),
}));
Multiple stores don't increase bundle size (Zustand is 1KB total). They give you clarity: components import only the stores they use. No global mega-store that knows about everything.
Pattern 2: Selectors for Re-Render Optimization
By default, Zustand uses shallow equality. Watching a selector (a function that picks a piece of state) lets you optimize further:
const useRoutesStore = create<RoutesStore>((set) => ({ ... }));
// Component A: listens only to routes length
const routeCount = useRoutesStore((state) => state.routes.length);
// Component B: listens to entire routes array
const routes = useRoutesStore((state) => state.routes);
// Component C: listens to a computed selector
const completedRoutes = useRoutesStore((state) =>
state.routes.filter(r => r.status === 'completed')
);
Each selector creates a new subscription. Update a route, only Component A rerenders (shallow equality detected the length didn't change). Update the entire routes array, Components B and C rerender. This is cheaper than Context — no top-down rerender cascade.
Pattern 3: Async Actions with API Calls
Fetch data in an action and update the store:
interface RoutesStore {
routes: Route[];
isLoading: boolean;
fetchRoutes: () => Promise<void>;
}
export const useRoutesStore = create<RoutesStore>((set) => ({
routes: [],
isLoading: false,
fetchRoutes: async () => {
set({ isLoading: true });
try {
const res = await fetch('/api/routes');
const routes = await res.json();
set({ routes, isLoading: false });
} catch (err) {
set({ isLoading: false });
console.error(err);
}
},
}));
Async actions are just regular functions that call `set`. No special middleware like redux-thunk. For React Query integration (recommended for server state), see Pattern 4.
Pattern 4: React Query + Zustand Boundary
Use Zustand for client state (UI, filters, selections) and React Query for server state (API data):
// src/stores/filtersStore.ts — client state only
export const useFiltersStore = create((set) => ({
sortBy: 'name',
filterBy: 'completed',
setSortBy: (sortBy) => set({ sortBy }),
setFilterBy: (filterBy) => set({ filterBy }),
}));
// src/hooks/useRoutes.ts — server state with React Query
import { useQuery } from '@tanstack/react-query';
export function useRoutes() {
const sortBy = useFiltersStore((s) => s.sortBy);
const filterBy = useFiltersStore((s) => s.filterBy);
return useQuery({
queryKey: ['routes', sortBy, filterBy],
queryFn: () => fetch(`/api/routes?sort=${sortBy}&filter=${filterBy}`).then(r => r.json()),
});
}
Zustand owns filters and UI state. React Query owns fetching, caching, and synchronization. The boundary is clean: Zustand doesn't touch the server, React Query doesn't touch the UI.
Six FAQs
How does Zustand handle TypeScript?
Zustand is fully typed. Define an interface for your store (state + actions), pass it as a generic to `create`, and TypeScript enforces that everything matches. No `any` casts, no "use as" operators. The type narrows correctly when you select a specific field.
Can I subscribe to a store outside React?
Yes. The store is just a JavaScript object. Call `store.subscribe(listener)` to watch for changes, or read state directly with `store.getState()`. Useful for logging, syncing to analytics, or running jobs outside components.
How do I debug Zustand with Redux DevTools?
Use the `devtools` middleware: `create(devtools((set) => ({ ... }), { name: 'userStore' })))`. Now you can time-travel through state changes in Redux DevTools. Zustand works with the Redux DevTools browser extension.
What if I need to access one store from another?
Use `useStore.getState()` inside an action of the other store. Avoid circular subscriptions. If stores are tightly coupled, you've got too many stores — merge them.
Can I use Zustand with Supabase?
Absolutely. Fetch data from Supabase and populate your Zustand store. For real-time data (subscriptions), combine Zustand with Supabase's `on` listener: when Supabase emits a change, update your Zustand store. See the Aidxn pricing page for how we handle this in production dashboards.
Should I persist everything to localStorage?
No. Persist only what survives a page reload — user preferences, filters, draft data. Never persist sensitive data (auth tokens, passwords). Check `typeof window` to avoid hydration mismatches with server-side rendering.
The Bottom Line
Zustand is the modern default for app state. It's 1KB, it gets out of your way, and it scales from a toggle to a multi-store dashboard without overhead. No providers, no boilerplate, no mental model you have to memorize. Use it for client state, combine it with React Query for server state, and use selectors to control rerenders. When you're ready to ship full-stack apps with clean state architecture, this is the stack that scales. For more on managing data at scale, read React Hook Form + Zod — The Form Pattern That Just Works.