Zustand vs Jotai vs Nano Stores 2026
Zustand vs Jotai vs Nano Stores: Micro State Management 2026
TL;DR
The Redux era is over for most apps. Zustand is the clear winner for React global state — 2.9kB, minimal boilerplate, flux-style store with a friendly API that doesn't require providers or reducers. Jotai takes the atomic model from Recoil (but done right) — individual atoms compose into derived state, each component subscribes to only what it reads, and async atoms remove the need for a separate data fetching layer for many use cases. Nano Stores is the framework-agnostic choice — works with React, Vue, Svelte, SolidJS, and Astro islands without the API changing. For React-only global state: Zustand. For fine-grained subscriptions and derived state: Jotai. For multi-framework apps (React + Svelte islands, Astro): Nano Stores.
Key Takeaways
- Zustand: 2.9kB gzipped — the smallest complete state management solution
- Jotai atoms update only subscribed components — zero unnecessary re-renders by default
- Nano Stores: 265 bytes — absurdly small, framework-agnostic, works everywhere
- Zustand: 9M+ weekly npm downloads — most popular of the three by a wide margin
- Jotai async atoms eliminate
useEffect + useStateboilerplate for async data - Zustand has no Context Provider — just import and use the store, anywhere
- Nano Stores works in Astro islands — share state between React and Vue components on the same page
Why Not Redux (or Zustand vs Redux)
Redux boilerplate for a counter:
- actions.ts (types + creators)
- reducer.ts
- store.ts
- Provider wrapper
- useSelector + useDispatch in component
≈ 50-100 lines
Zustand for the same counter:
const useCounter = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
≈ 5 lines
None of these libraries replace React Query/TanStack Query for server state — they're complementary. These tools manage client state (UI state, user preferences, shopping cart, auth, etc.).
Zustand: Minimal Global Store
Zustand uses a simplified flux pattern — stores are functions, state updates are shallow merges, and you subscribe to slices.
Installation
npm install zustand
Basic Store
import { create } from "zustand";
interface CounterStore {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Component — subscribe to specific slice
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return (
<button onClick={increment}>
Count: {count}
</button>
);
}
TypeScript Store with Immer
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartStore {
items: CartItem[];
isOpen: boolean;
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
immer((set, get) => ({
items: [],
isOpen: false,
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== id);
}),
updateQuantity: (id, quantity) =>
set((state) => {
const item = state.items.find((i) => i.id === id);
if (item) item.quantity = quantity;
}),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}))
);
Zustand with Persist Middleware
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
interface UserPreferences {
theme: "light" | "dark" | "system";
language: string;
notifications: boolean;
setTheme: (theme: UserPreferences["theme"]) => void;
setLanguage: (language: string) => void;
toggleNotifications: () => void;
}
export const usePreferencesStore = create<UserPreferences>()(
persist(
(set) => ({
theme: "system",
language: "en",
notifications: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleNotifications: () =>
set((state) => ({ notifications: !state.notifications })),
}),
{
name: "user-preferences", // localStorage key
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
theme: state.theme,
language: state.language,
notifications: state.notifications,
}),
}
)
);
Zustand Outside React
// Zustand stores are plain JS — use outside components
const { items, addItem } = useCartStore.getState();
// Subscribe outside React
const unsubscribe = useCartStore.subscribe(
(state) => state.items.length,
(count) => {
console.log(`Cart has ${count} items`);
updateCartBadge(count);
}
);
Slices Pattern for Large Apps
// Split large stores into composable slices
import { StateCreator } from "zustand";
interface AuthSlice {
user: User | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
interface UISlice {
sidebarOpen: boolean;
modal: string | null;
toggleSidebar: () => void;
openModal: (name: string) => void;
closeModal: () => void;
}
type AppStore = AuthSlice & UISlice;
const createAuthSlice: StateCreator<AppStore, [], [], AuthSlice> = (set) => ({
user: null,
isAuthenticated: false,
login: async (email, password) => {
const user = await authApi.login(email, password);
set({ user, isAuthenticated: true });
},
logout: () => set({ user: null, isAuthenticated: false }),
});
const createUISlice: StateCreator<AppStore, [], [], UISlice> = (set) => ({
sidebarOpen: false,
modal: null,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
openModal: (name) => set({ modal: name }),
closeModal: () => set({ modal: null }),
});
export const useStore = create<AppStore>()((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
}));
Jotai: Atomic State for React
Jotai models state as individual atoms — like React's useState but shared and composable. Components only re-render when their specific atom changes.
Installation
npm install jotai
Basic Atoms
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
// Primitive atoms
export const countAtom = atom(0);
export const userNameAtom = atom<string | null>(null);
export const themeAtom = atom<"light" | "dark">("dark");
// Derived atoms (read-only)
export const doubleCountAtom = atom((get) => get(countAtom) * 2);
export const isLoggedInAtom = atom((get) => get(userNameAtom) !== null);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubleCount = useAtomValue(doubleCountAtom); // Read-only, no setter needed
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}
function CountDisplayOnly() {
const count = useAtomValue(countAtom); // Subscribes to atom, no setter
return <span>{count}</span>;
}
function CountSetter() {
const setCount = useSetAtom(countAtom); // Gets setter only — no re-render on count change
return <button onClick={() => setCount(0)}>Reset</button>;
}
Async Atoms
import { atom } from "jotai";
// Async atom — works with Suspense
export const userAtom = atom(async () => {
const response = await fetch("/api/user");
return response.json() as Promise<User>;
});
// Dependent async atom
export const userPostsAtom = atom(async (get) => {
const user = await get(userAtom); // Waits for userAtom to resolve
const response = await fetch(`/api/users/${user.id}/posts`);
return response.json() as Promise<Post[]>;
});
import { Suspense } from "react";
import { useAtomValue } from "jotai";
function UserProfile() {
const user = useAtomValue(userAtom); // Suspends until loaded
return <h1>Hello, {user.name}</h1>;
}
// Wrap with Suspense
function App() {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile />
</Suspense>
);
}
Atom with localStorage
import { atomWithStorage } from "jotai/utils";
export const themeAtom = atomWithStorage<"light" | "dark">("theme", "dark");
export const languageAtom = atomWithStorage("language", "en");
export const sidebarOpenAtom = atomWithStorage("sidebar-open", true);
Family Atoms (per-ID state)
import { atomFamily } from "jotai/utils";
// Create an atom per ID — useful for list items
export const todoAtomFamily = atomFamily((id: string) =>
atom({ id, completed: false, text: "" })
);
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id));
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodo((t) => ({ ...t, completed: !t.completed }))}
/>
<span>{todo.text}</span>
</div>
);
}
Nano Stores: Framework-Agnostic Atoms
Nano Stores is designed for multi-framework apps — the same store works in React, Vue, Svelte, SolidJS, and Astro. At 265 bytes, it adds almost nothing to your bundle.
Installation
npm install nanostores
npm install @nanostores/react # React bindings
Defining Stores
// stores/user.ts — framework-agnostic
import { atom, map, computed, action } from "nanostores";
// Atom (primitive)
export const $count = atom(0);
export const $theme = atom<"light" | "dark">("dark");
// Map (object store)
export const $user = map<{
id: string | null;
name: string;
email: string;
isAuthenticated: boolean;
}>({
id: null,
name: "",
email: "",
isAuthenticated: false,
});
// Computed (derived)
export const $isAdmin = computed($user, (user) => user.id !== null && user.role === "admin");
// Actions (mutations)
export const increment = action($count, "increment", (store) => {
store.set(store.get() + 1);
});
export const login = action($user, "login", async (store, email: string, password: string) => {
const response = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
const user = await response.json();
store.set({ ...user, isAuthenticated: true });
});
Using in React
import { useStore } from "@nanostores/react";
import { $count, $user, increment } from "@/stores/user";
function Counter() {
const count = useStore($count);
return <button onClick={increment}>Count: {count}</button>;
}
function UserInfo() {
const user = useStore($user);
if (!user.isAuthenticated) return <LoginButton />;
return <span>Welcome, {user.name}</span>;
}
Using in Vue
<!-- Same store, different framework -->
<script setup>
import { useStore } from "@nanostores/vue";
import { $count, $user, increment } from "@/stores/user";
const count = useStore($count);
const user = useStore($user);
</script>
<template>
<button @click="increment">Count: {{ count }}</button>
<span>Welcome, {{ user.name }}</span>
</template>
Astro Islands — Sharing State Across Frameworks
---
// Layout.astro — Astro page with React + Vue islands
---
<html>
<body>
<!-- React island -->
<ReactCounter client:load />
<!-- Vue island — shares same store state -->
<VueUserInfo client:load />
</body>
</html>
// ReactCounter.tsx — reads same nanostores atom
import { useStore } from "@nanostores/react";
import { $count, increment } from "@/stores/user";
export function ReactCounter() {
const count = useStore($count);
return <button onClick={increment}>Count: {count}</button>;
}
<!-- VueUserInfo.vue — reads same nanostores atom -->
<script setup>
import { useStore } from "@nanostores/vue";
import { $user } from "@/stores/user";
const user = useStore($user);
</script>
<template>
<span>{{ user.name }}</span>
</template>
Feature Comparison
| Feature | Zustand | Jotai | Nano Stores |
|---|---|---|---|
| Bundle size | 2.9kB | 3.3kB | 265B |
| Model | Flux store | Atoms | Atoms |
| Framework | React | React | Any (React, Vue, Svelte, Solid) |
| No Provider needed | ✅ | ✅ | ✅ |
| Derived state | Via selectors | ✅ Computed atoms | ✅ computed() |
| Async state | Via middleware | ✅ Async atoms + Suspense | ✅ |
| Persistence | ✅ persist middleware | ✅ atomWithStorage | ✅ persistentMap |
| DevTools | ✅ Redux DevTools | ✅ Jotai DevTools | ❌ |
| TypeScript | ✅ Excellent | ✅ Excellent | ✅ Good |
| Server components | ✅ | ✅ | ✅ |
| Weekly downloads | 9M+ | 2M+ | 300k+ |
| GitHub stars | 50k | 18k | 4.5k |
When to Use Each
Choose Zustand if:
- React-only app and you want the most popular, best-documented micro state solution
- You're coming from Redux and want a similar flux-style pattern with 90% less boilerplate
- You need DevTools integration for debugging complex state
- Middleware (persist, immer, devtools) ecosystem matters
Choose Jotai if:
- Fine-grained reactivity is important — only re-render components that subscribe to changed atoms
- Async state management with Suspense integration is appealing
- You're building a large app with many isolated state "islands" (per-item state, atom families)
- You like React's
useStateAPI scaled up to global state
Choose Nano Stores if:
- Your app uses multiple frameworks (React + Vue, Astro islands, SolidJS + Svelte)
- Bundle size is critical — 265 bytes is unbeatable
- You want state that works the same way regardless of the framework rendering it
- You're building an Astro site with multiple framework islands
Ecosystem and Community Health
Zustand's 50k GitHub stars and 9M weekly downloads place it firmly in the "dominant choice" category for React state management. The library is maintained by Daishi Kato and has received contributions from hundreds of developers. The middleware ecosystem — devtools, persist, immer, subscribeWithSelector — covers the most common real-world requirements without pulling in extra packages. Zustand is the consensus recommendation in the React community for client-side state, appearing in T3 Stack tutorials, Next.js documentation examples, and major conference talks.
Jotai (also by Daishi Kato) is positioned differently — it's the right tool for a specific set of problems rather than a universal state management solution. The 18k GitHub stars and 2M weekly downloads represent genuinely enthusiastic adoption by developers who find the atomic model clicks for their use case. The jotai/utils package extending atoms with persistence, family patterns, and async operators makes Jotai a surprisingly complete solution for applications with complex derived state.
Nano Stores' 4.5k stars and 300k downloads are deceptive measures of its importance. In the Astro ecosystem, where React, Vue, Svelte, and Solid components coexist on the same page, Nano Stores is the recommended state sharing solution in Astro's official documentation. For Astro sites — which are increasingly popular for content-heavy websites, e-commerce, and marketing sites — Nano Stores is effectively the standard. The 265-byte size is genuinely remarkable for a library that provides atoms, maps, computed stores, and actions.
Real-World Adoption
Zustand is used in production at companies of all sizes. Notable adopters include teams at Amazon (AWS Console components), Adobe (Creative Cloud browser interfaces), and numerous Series A through Series C startups building their first React applications. The library's compatibility with React Server Components — stores remain useful for client components in a Next.js App Router app — means it hasn't been disrupted by the RSC transition that caused friction for Context-heavy state management.
Jotai is particularly popular in applications with complex data dependencies. A common example is a spreadsheet-like interface where cell values depend on other cells, or a configuration wizard where later steps depend on earlier answers. The atomic model makes these dependency graphs explicit and type-safe. Jotai's atomFamily pattern is widely used in list-based UIs where each list item has its own independent state — todo items, comment threads, expandable rows.
Nano Stores has seen the fastest growth of the three in 2025-2026 as Astro adoption accelerated. The Content-Driven Web renaissance — static site generators returning to prominence for performance and SEO reasons — brought Astro to the forefront, and Nano Stores came along for the ride. Developers building Astro e-commerce sites use Nano Stores for the shopping cart shared between a React checkout component and a Svelte product count badge.
Developer Experience Deep Dive
Zustand's best-in-class developer experience comes from a combination of the simple store API, the Redux DevTools integration, and the Immer middleware. The Redux DevTools browser extension works out of the box when you add the devtools middleware — every state change shows up with the action name, previous state, and next state. For debugging complex state flows in production bug reports, this time travel capability is invaluable.
The TypeScript experience for Zustand deserves special mention. The create<StoreType>() API with proper generics gives you full autocomplete for state and actions throughout your component tree. The slices pattern, where you split a large store into smaller functions that are merged at creation time, lets you maintain type safety across large applications with many pieces of global state.
Jotai's TypeScript experience is even stronger in some ways because atomic state is inherently typed at the declaration site. atom<User | null>(null) gives you User | null everywhere you use useAtom(userAtom). Derived atoms inherit their types from the atoms they read: atom((get) => get(userAtom)?.name ?? "Anonymous") correctly types as string. The potential footgun is atom identity — atoms should be defined at module scope, not inside components. Defining atoms inside components creates a new atom on every render, defeating the purpose of shared state.
Nano Stores' developer experience emphasizes simplicity over features. The map store type (for objects) and atom (for primitives) cover most use cases. The lack of DevTools integration is a genuine gap for complex debugging scenarios, but the simplicity means there's less to debug in the first place.
Performance Analysis
In a standard React application with 50 components, the performance difference between Zustand, Jotai, and Nano Stores is unmeasurable in practice. All three are implemented correctly — they only trigger re-renders in components that subscribe to changed state. The micro-benchmark results that show differences are typically measuring things that don't matter at real application scale.
Where the performance difference becomes visible is in applications with hundreds of components and frequently changing state. Jotai's atomic model has a theoretical advantage here: a component subscribing to countAtom only re-renders when countAtom changes, never when other atoms change. In Zustand, a component using useCounterStore(state => state.count) achieves the same result via selector optimization, but it requires developers to correctly write the selector. A common Zustand mistake is useCounterStore() (selecting the entire state), which re-renders on every store change regardless of what changed.
Nano Stores on the edge (Cloudflare Workers, Vercel Edge) performs well because the 265-byte library has minimal startup cost. For edge functions that need shared state within a single request context, Nano Stores' light weight is a genuine advantage.
When to Combine with React Query
A common misconception is that Zustand, Jotai, or Nano Stores replace TanStack Query (React Query). They don't — they solve different problems. TanStack Query manages server state: cache, stale-while-revalidate, deduplication, and background refetching. Zustand/Jotai/Nano Stores manage client state: UI state that doesn't come from a server.
The practical combination for most applications is TanStack Query for data fetching alongside Zustand for UI state (sidebar open, selected items, modal visibility, user preferences). This is so common that the T3 Stack and create-t3-app scaffold exactly this combination: tRPC + TanStack Query for server state, Zustand for client state.
Migration Guide
To migrate from Redux Toolkit to Zustand: identify the slice reducers and map each slice to a Zustand store. Async thunks become async functions inside the store. useSelector becomes selector functions passed to useStore. The migration is straightforward for most applications and typically reduces the total state management code by 60-70%.
To adopt Jotai from Zustand: Jotai is complementary rather than a replacement. Large single stores work better in Zustand; fine-grained per-item state and complex derived state work better in Jotai. Many teams run both: Zustand for global app state (auth, cart, UI preferences), Jotai for component-level derived state.
Final Verdict 2026
Zustand is the correct default for React applications. The 9M weekly downloads, excellent DevTools, robust middleware ecosystem, and straightforward API make it the lowest-risk choice with the widest community support. If you're unsure which to use, choose Zustand.
Jotai is the right choice when you have genuinely complex derived state, per-item state in lists, or async state that integrates with React Suspense. The atomic model pays off at scale in a way that's hard to replicate with a single-store pattern.
Nano Stores is the right choice for Astro projects, multi-framework applications, and edge-deployed JavaScript where bundle size is critical. For pure React applications, it's a valid choice but lacks the ecosystem depth of Zustand.
Methodology
Data sourced from GitHub repositories (star counts as of February 2026), npm weekly download statistics (February 2026), official documentation for all three libraries, bundle size from bundlephobia.com, and community discussions from the Jotai Discord, r/reactjs, and the Astro Discord (for Nano Stores). React ecosystem survey data from the State of JavaScript 2025.
Related: Best Monorepo Tools 2026, Best JavaScript Testing Frameworks 2026, Best Next.js Auth Solutions 2026
Compare Jotai and Zustand package health on PkgPulse.