Skip to main content

Zustand vs Jotai vs Nano Stores: Micro State Management 2026

·PkgPulse Team

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 + useState boilerplate 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

FeatureZustandJotaiNano Stores
Bundle size2.9kB3.3kB265B
ModelFlux storeAtomsAtoms
FrameworkReactReactAny (React, Vue, Svelte, Solid)
No Provider needed
Derived stateVia selectors✅ Computed atomscomputed()
Async stateVia middleware✅ Async atoms + Suspense
Persistence✅ persist middleware✅ atomWithStorage✅ persistentMap
DevTools✅ Redux DevTools✅ Jotai DevTools
TypeScript✅ Excellent✅ Excellent✅ Good
Server components
Weekly downloads9M+2M+300k+
GitHub stars50k18k4.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 useState API 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

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: React Query v5 vs SWR vs Apollo Client for server state management that complements these client state solutions, or React vs Next.js vs Remix for the framework context these libraries operate in.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.