Zustand vs Jotai 2026: Choosing Between Poimandres
TL;DR
Zustand for app-level state; Jotai for component-level atomic state. Zustand (~10M weekly downloads) uses a single store model — great for global app state like auth, cart, or UI state. Jotai (~3M downloads) uses React-native atomic state that lives as close to components as possible. Both are from the same team (Poimandres), both are excellent, and the choice is about mental model preference more than capability.
Key Takeaways
- Zustand: ~10M weekly downloads — Jotai: ~3M (npm, March 2026)
- Zustand uses a store — Jotai uses atoms (more like React's useState)
- Both are from Poimandres — same team that builds React Three Fiber
- Zustand works outside React — Jotai is React-first
- Jotai atoms are composable — can derive state from other atoms without selectors
Mental Models: Store vs Atoms
The fundamental difference between Zustand and Jotai is not about features — it's about how you think about state. Zustand's model is a store: a single object that contains related state and the actions that modify it. Components subscribe to the parts of the store they care about, and only re-render when those specific slices change. It's conceptually similar to Redux but without the boilerplate. The store is a global singleton — it exists at the module level, outside of React's component tree, and components tap into it via hooks.
Jotai's model is atoms: tiny, independent units of state that can be composed together. An atom is similar to useState but it lives outside any specific component and can be shared across the React tree. Derived atoms read from other atoms and automatically update when their dependencies change. This "bottom-up" approach is particularly natural for state that starts local but needs to be shared — you start with an atom, and if two components need the same atom, you just import it in both.
// Zustand — store-centric (think Redux without boilerplate)
// State and actions live together in a single store object
import { create } from 'zustand';
interface AuthStore {
user: User | null;
isLoading: boolean;
error: string | null;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
}
const useAuthStore = create<AuthStore>((set) => ({
user: null,
isLoading: false,
error: null,
login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const user = await api.login(credentials);
set({ user, isLoading: false });
} catch (err) {
set({ error: (err as Error).message, isLoading: false });
}
},
logout: () => set({ user: null }),
}));
// Components subscribe to exactly what they need
const user = useAuthStore(state => state.user);
const login = useAuthStore(state => state.login);
// Jotai — atom-centric (think useState but shareable)
// State lives in composable atoms, not a central store
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
const userAtom = atom<User | null>(null);
const isLoadingAtom = atom(false);
const errorAtom = atom<string | null>(null);
// Derived atom — combines multiple atoms into a computed value
const isAuthenticatedAtom = atom((get) => get(userAtom) !== null);
// Write atom — encapsulates the login action
const loginAtom = atom(
null, // read: not needed
async (get, set, credentials: Credentials) => {
set(isLoadingAtom, true);
set(errorAtom, null);
try {
const user = await api.login(credentials);
set(userAtom, user);
} catch (err) {
set(errorAtom, (err as Error).message);
} finally {
set(isLoadingAtom, false);
}
}
);
// Components use only the atoms they need
function LoginButton() {
const login = useSetAtom(loginAtom); // Write-only
return <button onClick={() => login(credentials)}>Login</button>;
}
function Header() {
const isAuthenticated = useAtomValue(isAuthenticatedAtom); // Read-only
const user = useAtomValue(userAtom);
return isAuthenticated ? <span>{user?.name}</span> : <LoginButton />;
}
The Zustand version collocates all auth-related state and logic in one place. The Jotai version separates concerns into individual atoms and lets components subscribe to precisely the state they need. Neither approach is wrong — they reflect different opinions about where state organization belongs.
One practical implication of the store-vs-atoms model is how you organize state as your application grows. With Zustand, the natural evolution is to create multiple stores — one for auth, one for cart, one for UI state — and import whichever store is needed. With Jotai, you create more atoms and group related atoms in the same file or directory. Both approaches scale well, but Zustand's store boundaries tend to be more explicit and easier to audit at a glance, while Jotai's atom graph can spread organically across many files in a way that's either flexible or hard to follow, depending on team discipline.
Derived State and Selectors
Derived state — values computed from other state — is where the two libraries diverge most clearly in practice. Zustand's approach is selector-based: you pass a selector function to useCartStore() that extracts and computes the value you need. For simple selectors, this is fine. For expensive computations, you can add zustand-middleware for memoization.
Jotai's derived atoms are a first-class feature. You define an atom that reads from other atoms and returns a computed value. The derived atom automatically updates when its dependencies change, and Jotai tracks those dependencies automatically — you don't declare them manually. This makes complex derived state more composable and easier to test in isolation.
// Zustand — selectors for derived state
import { useCartStore } from '@/stores/cart';
// Basic selector
const items = useCartStore(state => state.items);
const total = useCartStore(state =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
// For expensive computations with stable references:
import { useShallow } from 'zustand/react/shallow';
const { items, total } = useCartStore(
useShallow(state => ({ items: state.items, total: state.total }))
);
// Jotai — atoms compose naturally without explicit selectors
const cartItemsAtom = atom<CartItem[]>([]);
// Derived atoms — each is independently subscribable
const cartTotalAtom = atom((get) =>
get(cartItemsAtom).reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const cartCountAtom = atom((get) => get(cartItemsAtom).length);
const electronicsAtom = atom((get) =>
get(cartItemsAtom).filter(i => i.category === 'electronics')
);
const electronicsTotalAtom = atom((get) =>
get(electronicsAtom).reduce((sum, i) => sum + i.price, 0)
);
// Each component only re-renders when its specific atom changes
function CartBadge() {
const count = useAtomValue(cartCountAtom); // Only re-renders when count changes
return <span>{count}</span>;
}
Async State Handling
Async state is handled differently enough that it can tip the decision for data-heavy apps. Zustand's approach is explicit: your actions call async functions, update loading/error state manually with set(), and components respond to those state changes. This is familiar and gives you full control over the loading and error experience.
Jotai integrates directly with React Suspense for async atoms. An async atom is a regular atom whose read function returns a Promise. When a component reads a loading async atom, Jotai throws the Promise, React catches it, and the nearest Suspense boundary shows the fallback. This is React's intended async model and Jotai leans into it naturally. For apps already using Suspense, Jotai's async atoms require dramatically less boilerplate than manually tracking loading states.
// Zustand — async actions in store (manual loading state)
const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id: string) => {
set({ loading: true, error: null });
try {
const user = await fetch(`/api/users/${id}`).then(r => r.json());
set({ user, loading: false });
} catch (err) {
set({ error: (err as Error).message, loading: false });
}
},
}));
// Jotai — async atoms (Suspense-friendly, no manual loading state)
import { atom, useAtomValue } from 'jotai';
import { loadable } from 'jotai/utils';
const userIdAtom = atom<string | null>(null);
// Async atom — automatically handles loading with Suspense
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
if (!id) return null;
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<User>;
});
// Use with Suspense (loading state is automatic):
function UserProfile() {
const user = useAtomValue(userAtom); // Suspends while loading
return <div>{user?.name}</div>;
}
// Or opt out of Suspense with loadable:
const loadableUserAtom = loadable(userAtom);
function UserProfileSafe() {
const state = useAtomValue(loadableUserAtom);
if (state.state === 'loading') return <Spinner />;
if (state.state === 'hasError') return <Error />;
return <div>{state.data?.name}</div>;
}
Using State Outside React
Zustand was designed to work outside React from the beginning. Any Zustand store exposes getState(), setState(), and subscribe() methods that you can call from non-React code. This is useful for sharing state between React components and vanilla JavaScript utilities, WebSocket handlers, service workers, or test files that don't render components.
Jotai is React-first. Its atoms are designed to integrate with React's rendering model. You can use atoms outside React via the store API (createStore()), but it's less ergonomic and clearly secondary to the React use case. If your project has significant non-React code that needs to share state with React components, Zustand's architecture is a better fit.
// Zustand — works outside React (great for non-React code)
import { useAuthStore } from '@/stores/auth';
// In a WebSocket handler (not a React component):
const { user } = useAuthStore.getState();
socket.send(JSON.stringify({ userId: user?.id }));
// Subscribe without React
const unsubscribe = useAuthStore.subscribe(
state => state.user,
(user) => {
if (!user) socket.disconnect();
}
);
// In tests:
useAuthStore.setState({ user: mockUser });
// Jotai — React-first, but has a store API for external use
import { createStore } from 'jotai';
import { userAtom } from '@/atoms/auth';
const store = createStore();
// Get current value outside React
const user = store.get(userAtom);
// Set value outside React
store.set(userAtom, newUser);
// Subscribe to changes
const unsubscribe = store.sub(userAtom, () => {
console.log('user changed:', store.get(userAtom));
});
Middleware and Persistence
Zustand ships with a robust middleware system. The persist middleware serializes store state to localStorage or sessionStorage automatically — you configure which keys to persist and the store handles hydration on page load. The immer middleware lets you write state mutations in mutable style (just like Immer in Redux Toolkit) while Zustand handles the immutable update internally. The devtools middleware integrates with Redux DevTools for time-travel debugging.
Jotai handles persistence through utility atoms from jotai/utils. The atomWithStorage utility creates an atom backed by localStorage or sessionStorage. The atomWithReset utility creates an atom that can be reset to its initial value. These utilities are more composable — you apply them per atom rather than to the whole store — which is consistent with Jotai's atom-first philosophy.
// Zustand — middleware applied to the whole store
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const useSettingsStore = create(
persist(
immer((set) => ({
theme: 'light' as 'light' | 'dark',
language: 'en',
setTheme: (theme: 'light' | 'dark') => set(state => { state.theme = theme; }),
setLanguage: (lang: string) => set(state => { state.language = lang; }),
})),
{
name: 'settings-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ theme: state.theme, language: state.language }),
}
)
);
// Jotai — persistence applied per atom
import { atom } from 'jotai';
import { atomWithStorage, atomWithReset } from 'jotai/utils';
// Each atom declares its own persistence
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
const languageAtom = atomWithStorage('language', 'en');
// Or use atomWithReset for atoms that can revert to default
const filterAtom = atomWithReset('all');
The Zustand middleware approach is more centralized — all persistence configuration lives in one place. Jotai's approach is more granular — each atom opts into persistence independently.
For testing, Zustand's external state access is particularly ergonomic. In unit tests you can call useStore.setState({ user: mockUser }) before rendering the component under test, without mocking React hooks or wrapping in a provider. Jotai's createStore() API can achieve the same thing, but you need to pass the store as a prop via <Provider store={testStore}>. Both are workable, but Zustand's testing setup is slightly simpler for components that consume global state.
Bundle size is essentially equal — both are around 3KB gzipped. This is a non-factor for most apps. Neither library adds meaningful overhead to your JavaScript bundle, and tree-shaking works well with both since they export named functions rather than monolithic objects.
Package Health
| Metric | zustand | jotai |
|---|---|---|
| Weekly downloads | ~10M | ~3M |
| GitHub stars | ~48k | ~18k |
| Maintainer | Poimandres | Poimandres |
| Latest version | v5 stable | v2 stable |
| Bundle size | ~3KB gzipped | ~3KB gzipped |
| TypeScript | Full support | Full support |
Both packages are maintained by Poimandres, the same open-source collective that maintains React Three Fiber, Valtio, and several other prominent React ecosystem libraries. Both are extremely well-maintained, have nearly identical bundle sizes, and have full TypeScript support. Download count difference reflects Zustand's broader use case as a Redux alternative — it's the most popular lightweight global state manager in the React ecosystem, while Jotai addresses the more specific niche of atomic, Suspense-friendly state.
It's worth noting that choosing between them is not a permanent decision. The libraries have no overlapping API surface, so using both in the same project is completely reasonable — Zustand for global app state that multiple parts of the app share, and Jotai for fine-grained component-level state within a specific feature. Many production apps do exactly this. The Poimandres team builds their libraries to be composable rather than competing.
When to Choose
Choose Zustand when:
- Managing global app-level state: auth status, user settings, shopping cart, notifications
- You need to access or modify state outside of React components (SSR, WebSockets, tests)
- Your team is migrating from Redux — the mental model is similar with far less boilerplate
- You want explicit store structure with all related state and actions colocated
- You need middleware like
immer,persist, ordevtoolsfrom the zustand ecosystem
Choose Jotai when:
- You have fine-grained component-level state that needs to be shared between sibling or cousin components
- Your app makes heavy use of React Suspense for data fetching
- You want derived/computed state that composes naturally without explicit selectors
- State relationships are complex — atoms building on atoms building on atoms
- You prefer a bottom-up approach where state emerges from component needs rather than top-down store design
For a deep dive on download trends and release cadence, see the Zustand vs Jotai comparison page. If you're evaluating Zustand against other store-based solutions, the MobX vs Zustand article covers the reactive vs explicit state management tradeoff. View current Zustand package stats on the Zustand package page.
See the live comparison
View zustand vs. jotai on PkgPulse →