Switch Language
Toggle Theme

Next.js State Management Guide: Zustand vs Jotai Practical Comparison

Introduction

It was late Saturday night. I was staring at error messages on my screen, modifying a Redux action creator for the 17th time. The project had just one shopping cart feature, but I’d already written three configuration files.

That’s when it hit me: why does this have to be so complicated?

To be honest, many developers face this dilemma. The project isn’t that big—using Redux feels like bringing a tank to the grocery store. Switch to Context API? One state update and half the page re-renders. That sea of red in the performance panel just hurts to look at.

That’s why Zustand and Jotai have exploded in popularity these past couple years—they promise “lightweight” and “high performance.” But here comes the new question: which one should you choose?

To be honest, I was confused at first too. Zustand claims simplicity, Jotai brags about performance—both sound pretty convincing. It wasn’t until I used them both in real projects that I finally understood their fundamental differences.

In this article, I’ll walk you through:

  • Why Redux and Context aren’t ideal (real gotchas)
  • What makes Zustand and Jotai fundamentally different
  • Which scenarios call for which solution (decision tree)
  • How to use them in Next.js App Router (lessons from the trenches)

Let’s dive right in.

Why Not Redux or Context?

Where Redux Gets “Heavy”

Let’s start with Redux. It’s not bad—it’s just overkill for many projects.

You need to write action types, action creators, reducers, and configure the store. A simple “add to cart” feature might touch three or four different files. After a while, all that boilerplate code gets really tedious.

What’s more, if you have newcomers on the team, Redux has quite a steep learning curve. What’s a dispatch? Why use pure functions? What’s middleware for? These concepts take time to grasp.

For small projects like a to-do list or personal blog, Redux is like taking a tank to buy groceries—you’ll get there, but it’s unnecessary.

Context API’s Performance Trap

What about Context? Sure, it’s simple, built-in, no external library needed.

But there’s one big problem: performance.

Context works like this: whenever the Provider’s value changes, all components consuming that Context re-render. Even if you only use one field, the entire component still goes through the render cycle.

I once built a form using Context to manage field state. One input’s onChange triggered re-renders across 20 components on the page. Looking at that flame graph in Chrome DevTools was just painful.

You can optimize with memo, useMemo, splitting Contexts—but honestly, the optimized code isn’t much simpler than Redux anyway.

Why Lightweight Solutions Appeal

That’s why Zustand and Jotai are so popular.

Their promise is crystal clear:

  • Simple API, quick to learn (Zustand takes 10 minutes)
  • Performance optimization built-in, no manual work needed
  • Tiny bundle size (Zustand is just 1KB, even smaller gzipped)

The numbers back this up too. According to 2025 statistics, Zustand usage grew 150% in the past year. More and more developers are leaving Redux behind for these lightweight alternatives.

But that raises a new question: Zustand or Jotai?

Core Differences: Zustand vs Jotai

These two libraries both claim to be “lightweight state management,” but their underlying design philosophies are completely different.

State Model: Single Store vs Atomic Units

The official docs say it perfectly: “Zustand is like Redux. Jotai is like Recoil.”

Zustand is essentially a simplified Redux. You have one store with all your state inside. Think of it like a department store—everything centrally managed.

// Zustand: one big store
const useStore = create((set) => ({
  user: null,
  cart: [],
  theme: 'light',
  // All state lives here
}))

Jotai takes an atomic approach. Each piece of state is an independent atom, like individual market stalls operating independently.

// Jotai: independent atoms
const userAtom = atom(null)
const cartAtom = atom([])
const themeAtom = atom('light')

This design difference directly determines which scenarios they’re suited for.

Storage Location: Outside React vs Inside Component Tree

Zustand’s store is module-level, living outside React. You can import and update it anywhere, no Provider needed.

Jotai’s atoms live in the component tree, relying on Context. You must wrap your root component with a Provider for atoms to share state across components.

What does this mean?

If you need to update state outside React components (like in utility functions or WebSocket callbacks), Zustand is much more convenient. Jotai can do it too, but you’ll need workarounds.

Performance: Manual Optimization vs Automatic

Performance-wise, they take different strategies.

Jotai’s atomic subscription is optimal by default. Components only subscribe to the atoms they use—other atom updates won’t trigger re-renders.

Zustand requires you to use selectors for optimization:

// Not recommended: subscribe to entire store
const store = useStore()

// Recommended: use selector to subscribe only to what you need
const user = useStore(state => state.user)

That said, Zustand’s selectors aren’t complicated to write, and they’re quite intuitive. You just need to remember to use them, or you might run into issues.

Quick Summary

  • Zustand: Single store, outside React, requires manual selectors
  • Jotai: Atomic atoms, inside React, performance automatically optimized

Which one to choose depends on your project characteristics.

When to Choose Zustand?

Let’s talk Zustand first. If your project fits these characteristics, Zustand is basically your best bet.

Small to Medium Apps That Don’t Need Complexity

Honestly, most projects don’t need complex state management.

An e-commerce site’s global state typically includes: user info, shopping cart, theme config. For scenarios like this, Zustand is perfect.

Its API is so simple it barely needs learning. Check out this complete example:

// store.js
import create from 'zustand'

const useStore = create((set) => ({
  cart: [],
  addToCart: (item) => set((state) => ({
    cart: [...state.cart, item]
  })),
  removeFromCart: (id) => set((state) => ({
    cart: state.cart.filter(item => item.id !== id)
  })),
}))

// CartButton.jsx
function CartButton() {
  const addToCart = useStore(state => state.addToCart)
  return <button onClick={() => addToCart(item)}>Add to Cart</button>
}

// CartCount.jsx
function CartCount() {
  const count = useStore(state => state.cart.length)
  return <span>{count}</span>
}

See that? No Provider, no action types, no reducer. Just define state and methods, then use them directly.

Team newcomers can get up to speed in 10 minutes.

Updating State Outside React

This is one of Zustand’s unique advantages.

Say you have a WebSocket connection that needs to update state when messages arrive:

// websocket.js
import { useStore } from './store'

socket.on('message', (data) => {
  // Call store methods directly, no need to be in a component
  useStore.getState().updateMessages(data)
})

Or in a utility function where you need to check current state:

// utils.js
import { useStore } from './store'

export function checkPermission() {
  const user = useStore.getState().user
  return user?.role === 'admin'
}

For scenarios like these, Jotai isn’t as convenient. Its atoms are bound to the component tree, requiring workarounds for outside access.

Next.js SSR Friendly

Zustand’s Next.js support is particularly solid.

The official docs have a dedicated chapter on Next.js integration, providing App Router best practices. Plus there’s tons of community wisdom—you can usually find solutions when you hit issues.

If you’re using Next.js 13+ App Router, Zustand is currently one of the most stable choices. I’ll cover configuration details later.

When Zustand Isn’t the Best Fit

But there’s one scenario where Zustand might not be optimal: complex derived relationships between states.

Like if you have a filter with 10 filter conditions, where each condition’s available options depend on other conditions’ current values. In scenarios like this, Zustand code gets pretty convoluted.

That’s when Jotai’s atomic design comes in handy.

When to Choose Jotai?

Jotai’s atomic design is genuinely brilliant in certain scenarios.

Complex State Dependencies

This is Jotai’s sweet spot.

Imagine you’re building a product filter:

  • You have “brand,” “price range,” “rating” filter conditions
  • Available brands list depends on current price range
  • Final product list is determined by all filter conditions combined

With Zustand, you’d manually manage these dependencies—code gets messy fast.

Jotai’s approach is much cleaner:

// Base atoms
const brandAtom = atom([])
const priceRangeAtom = atom([0, 1000])
const ratingAtom = atom(0)

// Derived atom: available brands (depends on price range)
const availableBrandsAtom = atom((get) => {
  const priceRange = get(priceRangeAtom)
  return fetchBrands(priceRange) // Auto-responds to priceRange changes
})

// Derived atom: filtered products (depends on all filter conditions)
const filteredProductsAtom = atom((get) => {
  const brands = get(brandAtom)
  const priceRange = get(priceRangeAtom)
  const rating = get(ratingAtom)
  return products.filter(/* filtering logic */)
})

See how each atom only cares about the other atoms it depends on? Jotai automatically tracks dependencies. When one changes, related atoms update automatically.

Using it in components is simple too:

function FilterPanel() {
  const [brands, setBrands] = useAtom(brandAtom)
  const availableBrands = useAtomValue(availableBrandsAtom)
  // brands changes, availableBrands recalculates automatically
}

For scenarios like this, Jotai’s code is way cleaner than Zustand’s.

Peak Performance Requirements

Jotai’s atomic subscription delivers genuinely great performance.

Imagine a real-time dashboard with 50 components, each displaying different data metrics. With Context or unoptimized Zustand, one data update might trigger a cascade of component re-renders.

Jotai doesn’t do that. Each component only subscribes to its own atom—other atom updates don’t affect it at all.

The official docs make it clear: “This is the most performant by default.” Components only subscribe to specific atoms, only re-rendering when those atoms change.

Large Apps Needing Code Splitting

Jotai’s atoms support lazy loading.

You can scatter atoms across different files, importing only when needed. This really helps large app initial load times.

Zustand stores are usually one big chunk. While you can split them, it’s not as natural as with Jotai.

Projects Heavily Using Suspense

If your project makes heavy use of React Suspense (like for async data loading), Jotai has native support.

Async atoms are particularly intuitive to write:

const userAtom = atom(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

function UserProfile() {
  const user = useAtomValue(userAtom)
  // Auto Suspense, waits for data load before rendering
  return <div>{user.name}</div>
}

While Zustand can work with Suspense, it needs extra wrapping—not as smooth as Jotai.

Jotai’s Learning Curve

That said, Jotai’s concepts are a bit more involved than Zustand’s.

Reading/writing atoms, derived atoms, async atoms—these concepts take some time to understand. If you have newcomers on the team, they’ll get up to speed slower than with Zustand.

And Jotai’s docs, to be honest, aren’t as user-friendly as Zustand’s. You’ll need to read through a few times to really grasp all the API usage.

Next.js App Router Best Practices

Theory covered, let’s get practical. There are some gotchas you should know about when using these libraries in Next.js 13+ App Router.

The Right Way to Use Zustand in Next.js

Big Gotcha: Don’t Use Global Store

Many people (myself included at first) write it like this directly:

// ❌ Wrong: global store
import create from 'zustand'

const useStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user })
}))

This works fine for client-side rendering, but in Next.js’s SSR environment, this store gets shared across multiple requests. User A’s data might be visible to User B—security risk.

The official recommended approach is the Store Factory pattern:

// lib/store.js
import { createStore } from 'zustand/vanilla'

export function createUserStore(initialState) {
  return createStore((set) => ({
    user: initialState?.user || null,
    setUser: (user) => set({ user })
  }))
}

Then create a Provider in a client component:

// components/StoreProvider.jsx
'use client'

import { createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { createUserStore } from '@/lib/store'

const StoreContext = createContext(null)

export function StoreProvider({ children, initialState }) {
  const storeRef = useRef()
  if (!storeRef.current) {
    storeRef.current = createUserStore(initialState)
  }

  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  )
}

export function useUserStore(selector) {
  const store = useContext(StoreContext)
  return useStore(store, selector)
}

Use it in root layout:

// app/layout.jsx
import { StoreProvider } from '@/components/StoreProvider'

export default function RootLayout({ children }) {
  // Can prefetch data here
  const initialState = { user: null }

  return (
    <html>
      <body>
        <StoreProvider initialState={initialState}>
          {children}
        </StoreProvider>
      </body>
    </html>
  )
}

This way each request gets its own store—no data leakage.

Key Points:

  • Server Components can’t directly read/write store
  • Data prefetching happens server-side, passed to client via initialState
  • Don’t block root layout with data fetching—impacts performance

Jotai’s SSR Hydration Issues

Jotai’s biggest gotcha in Next.js is hydration errors.

Must Create Independent Provider Per Request:

// app/providers.jsx
'use client'

import { Provider } from 'jotai'

export function Providers({ children }) {
  return <Provider>{children}</Provider>
}
// app/layout.jsx
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Handling Server Data:

If you need to inject server data into atoms, use useHydrateAtoms:

'use client'

import { useHydrateAtoms } from 'jotai/utils'
import { userAtom } from '@/atoms'

export function HydrateAtoms({ initialUser, children }) {
  useHydrateAtoms([[userAtom, initialUser]])
  return children
}

But there’s a gotcha: useHydrateAtoms only works on initial render. If you use router.push to navigate in App Router, the second visit to the same atom won’t re-hydrate.

Solutions:

  1. Put Provider in template.tsx instead of layout.tsx (recreates on each route)
  2. Or use page-level Provider instead of global Provider

atomWithStorage hydration errors:

If you use atomWithStorage to store form data, you might hit server render vs client hydration mismatch:

  • Server: form is empty
  • Client: reads from localStorage, form has values
  • Result: React hydration mismatch error

Solution: repopulate form in useEffect, or use useHydrateAtoms + useSyncExternalStore.

Universal Principles (Apply to Both Libraries)

  1. Server Components Can’t Use State Management

    • Server Components don’t have hooks, can’t use useStore or useAtom
    • Mark components needing state as 'use client'
  2. Provider Position Matters

    • Place it as deep as possible, helps Next.js optimize static parts
    • But should still be accessible to all components needing state
  3. Avoid Blocking Root Layout

    • Don’t use await fetch() in root layout for user data
    • This negates streaming and Server Components performance benefits
    • Use dedicated client components for data fetching and initialization

I’ve hit all these gotchas. Remember these principles to save yourself debugging time.

My Selection Recommendations

After all that, you might still be wondering: which one should I choose?

To be honest, there’s no standard answer. But I can give you a decision tree.

Quick Decision Tree

If your project is…

  1. Simple personal project / small app
    → Start with Context API, don’t switch if it’s enough
    → If you hit performance issues, go with Zustand

  2. Medium SaaS app / e-commerce site
    → Go straight to Zustand
    → Simple, stable, team picks it up easily

  3. Complex dashboard / real-time app
    → Consider Jotai
    → Complex state dependencies, Jotai is cleaner

  4. Large team / strict standards required
    → Redux Toolkit might fit better
    → Has more constraints and best practices

  5. Need to update state outside React
    → Zustand is your only choice
    → Jotai can do it too, but less naturally

  6. Heavy Suspense usage
    → Jotai is smoother
    → Async atoms natively supported

Progressive Strategy

I personally recommend progressive selection:

  1. Stage 1: Context API

    • Project just starting, not much state, use Context first
    • If it works, don’t change—don’t over-optimize early
  2. Stage 2: Zustand

    • Context performance starts becoming an issue
    • Or global state gets more complex
    • Zustand handles 80% of scenarios
  3. Stage 3: Jotai or Stick with Zustand

    • State dependencies particularly complex → Jotai
    • Otherwise stick with Zustand
    • Don’t chase tech for tech’s sake

Can You Mix Them?

Yes!

Zustand and Jotai don’t conflict. I’ve seen projects that:

  • Use Zustand for global config (user, theme)
  • Use Jotai for complex form state

This works perfectly fine. Choose the right tool for the specific problem.

My Practical Experience

Sharing my own choices:

  • Personal blog: No state management needed, Server Components + URL state suffices
  • Admin projects: Zustand, paired with React Query for server state
  • Real-time dashboards: Jotai, state dependencies too complex, Zustand gets convoluted

The key is: don’t obsess over tech selection at the start. Use the simplest solution first, upgrade when you hit problems.

For many projects, Context API is enough. Don’t underestimate it.

Conclusion

Back to the opening question.

Redux too heavy? Indeed, for many projects it’s overkill.

Context performance issues? Yes, without optimization, re-renders do become a bottleneck.

Zustand or Jotai? Depends on your scenario:

  • Most cases, Zustand suffices—simple and stable
  • Complex state dependencies, Jotai is more elegant
  • Not sure? Try Zustand first, switch if it doesn’t work out

How to use in Next.js App Router? Remember three points:

  • Don’t use global store
  • Independent Provider per request
  • Server Components shouldn’t touch state

One last thing.

There’s no standard answer for tech selection. Don’t let comparison articles online dictate your choices—including this one. What matters most is choosing a solution you and your team are comfortable with that solves actual problems.

If you’re hesitating right now, my advice is: pick one, write a demo, try it out. 10 minutes of practice beats 10 articles.

Best of luck finding the right tool.

11 min read · Published on: Dec 19, 2025 · Modified on: Jan 22, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts