DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on Originally published at stacknotice.com

Zustand vs Jotai (2026): Store-Based vs Atomic State in React

Both Zustand and Jotai are from the same creator and solve the same core problem: state management without Redux boilerplate. Both are ~3kb. Both work great with TypeScript. The difference is the mental model.

Zustand thinks in stores — centralized state with actions. Jotai thinks in atoms — small composable pieces that derive from each other.

Core Model

Zustand: Store + Selectors

// store/useCartStore.ts
export const useCartStore = create<CartStore>()(
  devtools(persist(
    (set, get) => ({
      items: [] as CartItem[],
      isOpen: false,

      addItem: (item) => set(state => {
        const existing = state.items.find(i => i.id === item.id)
        if (existing) {
          return { items: state.items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i) }
        }
        return { items: [...state.items, { ...item, quantity: 1 }] }
      }),

      clearCart: () => set({ items: [] }),
      toggleCart: () => set(state => ({ isOpen: !state.isOpen }))
    }),
    { name: 'cart' }
  ))
)

// Components subscribe only to what they need
function CartButton() {
  const count = useCartStore(state => state.items.reduce((s, i) => s + i.quantity, 0))
  const toggle = useCartStore(state => state.toggleCart)
  return <button onClick={toggle}>Cart ({count})</button>
  // Only re-renders when count changes
}
Enter fullscreen mode Exit fullscreen mode

Jotai: Atoms Compose

// store/cartAtoms.ts
export const cartItemsAtom = atomWithStorage<CartItem[]>('cart-items', [])
export const cartOpenAtom = atom(false)

// Derived atoms — auto-memoized, shared across all subscribers
export const cartCountAtom = atom(get =>
  get(cartItemsAtom).reduce((s, i) => s + i.quantity, 0)
)
export const cartTotalAtom = atom(get =>
  get(cartItemsAtom).reduce((s, i) => s + i.price * i.quantity, 0)
)

// Write atom for actions
export const addItemAtom = atom(null, (get, set, item: Omit<CartItem, 'quantity'>) => {
  const items = get(cartItemsAtom)
  const existing = items.find(i => i.id === item.id)
  set(cartItemsAtom, existing
    ? items.map(i => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i)
    : [...items, { ...item, quantity: 1 }]
  )
})

function CartButton() {
  const count = useAtomValue(cartCountAtom)
  const setOpen = useSetAtom(cartOpenAtom)
  return <button onClick={() => setOpen(p => !p)}>Cart ({count})</button>
}
Enter fullscreen mode Exit fullscreen mode

Async State: The Biggest Practical Difference

Zustand — manual loading/error management

const useUserStore = create<UserStore>()((set) => ({
  user: null, loading: false, error: null,
  fetchUser: async (id) => {
    set({ loading: true, error: null })
    try {
      set({ user: await api.users.getById(id), loading: false })
    } catch (err) {
      set({ error: (err as Error).message, loading: false })
    }
  }
}))
Enter fullscreen mode Exit fullscreen mode

Jotai — async atoms + Suspense

// The fetch IS the atom — no manual loading state
export const userQueryAtom = atomWithQuery(get => ({
  queryKey: ['user', get(userIdAtom)],
  queryFn: () => api.users.getById(get(userIdAtom)!),
  enabled: !!get(userIdAtom)
}))

// Component suspends until data resolves
function ProfileContent() {
  const [{ data: user }] = useAtom(userQueryAtom)
  return <Profile user={user!} />
}

// Suspense boundary handles the loading state
function Page() {
  return (
    <Suspense fallback={<Spinner />}>
      <ProfileContent />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

Derived State

Zustand selectors recompute on every render (use useShallow for complex objects). Jotai derived atoms are memoized automatically — all subscribers share the same cached computation.

// Zustand — manually memoize expensive selectors
const result = useCartStore(useShallow(state => ({
  items: state.items,
  total: state.items.reduce((s, i) => s + i.price * i.quantity, 0)
})))

// Jotai — memoization is built in, computed once per change
const expensiveItemsAtom = atom(get => get(cartItemsAtom).filter(i => i.price > 100))
const expensiveTotalAtom = atom(get =>
  get(expensiveItemsAtom).reduce((s, i) => s + i.price * i.quantity, 0)
)
// Multiple components using expensiveTotalAtom share the same result
Enter fullscreen mode Exit fullscreen mode

Decision Framework

Situation Choose
Coming from Redux Zustand
State shared across many unrelated components Zustand
Complex actions with side effects Zustand
Redux DevTools matters Zustand
Suspense-based async pattern Jotai
Fine-grained, component-local state Jotai
atomWithQuery (TanStack integration) Jotai
Coming from Recoil Jotai

For most React apps, Zustand is the lower-friction starting point. Its mental model (a bag of values with operations) maps to how most developers already think about state. Jotai becomes genuinely better when state is highly reactive, granular, and benefits from automatic memoization across derived views.


Full article at stacknotice.com/blog/zustand-vs-jotai-2026

Top comments (0)