Zustand vs Jotai in 2026: Choosing Between Poimandres Libraries
·PkgPulse Team
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 Model Difference
// Zustand — store-centric (think Redux without boilerplate)
// State lives in a single store object
import { create } from 'zustand';
const useCartStore = create((set, get) => ({
items: [],
total: 0,
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => set((state) => {
const item = state.items.find(i => i.id === id);
return {
items: state.items.filter(i => i.id !== id),
total: state.total - (item?.price ?? 0),
};
}),
}));
// Usage: subscribe to exactly what you need
const items = useCartStore(state => state.items);
const total = useCartStore(state => state.total);
const addItem = useCartStore(state => state.addItem);
// Jotai — atom-centric (think useState but sharable)
// State lives in atoms, not a central store
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
const cartItemsAtom = atom([]);
const cartTotalAtom = atom((get) =>
get(cartItemsAtom).reduce((sum, item) => sum + item.price, 0)
);
// ↑ derived atom — automatically updates when cartItemsAtom changes
// Usage in components
function CartTotal() {
const total = useAtomValue(cartTotalAtom); // Read-only
return <div>Total: ${total}</div>;
}
function CartActions() {
const setItems = useSetAtom(cartItemsAtom); // Write-only
const addItem = (item) => setItems(prev => [...prev, item]);
return <button onClick={() => addItem({ id: 1, price: 29 })}>Add</button>;
}
Derived State
// Zustand — selectors for derived state
const useFilteredItems = () => useCartStore(
state => state.items.filter(item => item.category === 'electronics')
);
// For expensive computations, use a library like zustand/middleware
import { subscribeWithSelector } from 'zustand/middleware';
const useStore = create(
subscribeWithSelector((set) => ({ ... }))
);
// Jotai — atoms compose naturally
const allItemsAtom = atom([]);
// Derived atoms — no selector needed
const electronicsAtom = atom(
(get) => get(allItemsAtom).filter(i => i.category === 'electronics')
);
const electronicsTotalAtom = atom(
(get) => get(electronicsAtom).reduce((sum, i) => sum + i.price, 0)
);
// Write-capable derived atom
const todoCountAtom = atom(
(get) => get(allItemsAtom).length, // read
(get, set, newCount) => set(allItemsAtom, []) // write (reset example)
);
Async State
// Zustand — async actions in store
const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
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.message, loading: false });
}
},
}));
// Jotai — async atoms (Suspense-friendly)
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';
const userIdAtom = atom(null);
// Async atom — automatically handles loading/error 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();
});
// loadable wrapper — opt out of Suspense
const loadableUserAtom = loadable(userAtom);
function UserProfile() {
const loadableUser = useAtomValue(loadableUserAtom);
if (loadableUser.state === 'loading') return <Spinner />;
if (loadableUser.state === 'hasError') return <Error />;
return <div>{loadableUser.data.name}</div>;
}
Outside React
// Zustand — works outside React (great for non-React code)
const store = useCounterStore.getState();
store.increment();
// Subscribe without React
const unsubscribe = useCounterStore.subscribe(
state => state.count,
(count) => console.log('Count changed:', count)
);
unsubscribe();
// Useful for: SSR hydration, testing, vanilla JS code sharing state with React
// Jotai — React-first, but has a store API for external use
import { createStore } from 'jotai';
const store = createStore();
store.get(countAtom);
store.set(countAtom, 5);
store.sub(countAtom, () => console.log('changed'));
// Primarily used for testing and SSR
// Not as ergonomic as Zustand for non-React usage
Bundle Size
Zustand: ~3KB gzipped
Jotai: ~3KB gzipped
Both are tiny — not a decision factor.
When to Choose
Choose Zustand when:
- Global app-level state (auth, user settings, shopping cart)
- You need to access state outside of React (SSR, testing, vanilla JS)
- Team prefers explicit store structure
- Migrating from Redux — familiar mental model, less boilerplate
- You need middleware (devtools, immer, persist)
Choose Jotai when:
- Component-local state that needs to be shared between siblings/cousins
- Complex derived state from multiple sources
- You use React Suspense for data fetching
- You want atoms to live near the components that use them
- Building component libraries where atoms can be exported
Compare Zustand and Jotai package health on PkgPulse.
See the live comparison
View zustand vs. jotai on PkgPulse →