Skip to content

Web Development · Frontend

Zustand vs Jotai: React State Management Without Redux

Redux is overkill for most React apps: Zustand and Jotai cover most state needs with far less boilerplate. Here is when to use each and how they actually work.

Anurag Verma

Anurag Verma

8 min read

Zustand and Jotai: React State Management Without the Redux Ceremony

Sponsored

Share

The React state management conversation used to be mostly about Redux. That’s changed. Most new projects don’t use Redux at all. Two smaller libraries have taken over the middle ground: Zustand for shared application state, and Jotai for granular atomic state that needs to cross component boundaries. Both arrived alongside a broader shift toward less boilerplate across the React ecosystem, from React 19’s simplified patterns to the framework-level signals proposal working through TC39.

Both install in a single package. Neither requires a Provider wrapping your app. Neither generates boilerplate files. Both have TypeScript support that works without configuration.

Zustand

Zustand is a store library built around a hook. You define state and the functions that modify it in one place, then call that hook from any component that needs access.

import { create } from 'zustand'

interface CartStore {
  items: CartItem[]
  total: number
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
  clearCart: () => void
}

export const useCartStore = create<CartStore>((set) => ({
  items: [],
  total: 0,

  addItem: (item) =>
    set((state) => {
      const items = [...state.items, item]
      return {
        items,
        total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
      }
    }),

  removeItem: (id) =>
    set((state) => {
      const items = state.items.filter((i) => i.id !== id)
      return {
        items,
        total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
      }
    }),

  clearCart: () => set({ items: [], total: 0 }),
}))

Using the store from two components in different parts of the tree:

function CartBadge() {
  // Selector: this component only re-renders when items.length changes
  const count = useCartStore((state) => state.items.length)
  return <span className="badge">{count}</span>
}

function CheckoutBar() {
  const { total, clearCart } = useCartStore()
  return (
    <div>
      <span>Total: ${total.toFixed(2)}</span>
      <button onClick={clearCart}>Clear</button>
    </div>
  )
}

The selector pattern is the key detail. When you write useCartStore((state) => state.items.length), the component only re-renders when that specific value changes. Without the selector, the component re-renders on any store update. For a cart badge that only needs the count, that’s a meaningful difference.

Middleware

Zustand’s middleware system handles patterns that come up constantly:

Persist to localStorage:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const usePreferencesStore = create<PreferencesStore>()(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      density: 'comfortable' as 'compact' | 'comfortable',
      setTheme: (theme) => set({ theme }),
      setDensity: (density) => set({ density }),
    }),
    {
      name: 'user-preferences',
      // Only persist these fields; skip transient state
      partialize: (state) => ({ theme: state.theme, density: state.density }),
    }
  )
)

Connect to Redux DevTools:

import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      // Third argument to set is the action name shown in DevTools
      increment: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
      decrement: () => set((s) => ({ count: s.count - 1 }), false, 'decrement'),
    }),
    { name: 'MyStore' }
  )
)

Splitting a Large Store into Slices

A single store with 30 fields becomes hard to read. Slices let you split by domain without losing the unified store:

// stores/auth-slice.ts
export const createAuthSlice = (set) => ({
  user: null,
  isAuthenticated: false,
  login: (user) => set({ user, isAuthenticated: true }),
  logout: () => set({ user: null, isAuthenticated: false }),
})

// stores/ui-slice.ts
export const createUiSlice = (set) => ({
  sidebarOpen: false,
  toasts: [],
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  addToast: (toast) => set((s) => ({ toasts: [...s.toasts, toast] })),
})

// stores/index.ts
type BoundStore = AuthSlice & UiSlice

const useBoundStore = create<BoundStore>()((...args) => ({
  ...createAuthSlice(...args),
  ...createUiSlice(...args),
}))

export const useAuth = () => useBoundStore((s) => ({ user: s.user, login: s.login, logout: s.logout }))
export const useUi = () => useBoundStore((s) => ({ sidebarOpen: s.sidebarOpen, toggleSidebar: s.toggleSidebar }))

The exports at the bottom create domain-scoped hooks that components import. Components never need to know about the combined store shape.

Jotai

Jotai takes the opposite approach. Instead of a single store, you have atoms: small, independent units of state that live at the module level and can be shared across components. The mental model is closer to useState, but with sharing built in.

import { atom } from 'jotai'

// Primitive atoms
export const searchQueryAtom = atom('')
export const pageAtom = atom(1)
export const selectedItemIdAtom = atom<string | null>(null)

// Derived atom (read-only, computed from others)
export const searchParamsAtom = atom((get) => ({
  q: get(searchQueryAtom),
  page: get(pageAtom),
}))

Using atoms in components:

import { useAtom, useAtomValue, useSetAtom } from 'jotai'

function SearchInput() {
  const [query, setQuery] = useAtom(searchQueryAtom)
  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value)
        // Reset page when query changes
      }}
    />
  )
}

function PageDisplay() {
  // useAtomValue: subscribe to read, no setter needed
  const page = useAtomValue(pageAtom)
  return <span>Page {page}</span>
}

function PaginationControls() {
  // useSetAtom: only the setter, no subscription (no re-render on value change)
  const setPage = useSetAtom(pageAtom)
  return (
    <div>
      <button onClick={() => setPage((p) => p - 1)}>Prev</button>
      <button onClick={() => setPage((p) => p + 1)}>Next</button>
    </div>
  )
}

The useSetAtom pattern is worth noting. A component that only needs to trigger state changes shouldn’t re-render when the value changes. useSetAtom subscribes to the setter only.

Async Atoms with Suspense

Jotai handles asynchronous derived state through a clean Suspense integration:

import { atom } from 'jotai'
import { useAtomValue } from 'jotai'

const selectedUserIdAtom = atom<string | null>(null)

const userDataAtom = atom(async (get) => {
  const id = get(selectedUserIdAtom)
  if (!id) return null
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Failed to load user')
  return res.json() as Promise<User>
})

// This component automatically suspends while userDataAtom is loading
function UserDetail() {
  const user = useAtomValue(userDataAtom)
  if (!user) return <p>Select a user</p>
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}

// Wrap in Suspense in the parent
function UserSection() {
  return (
    <Suspense fallback={<Skeleton />}>
      <ErrorBoundary fallback={<ErrorState />}>
        <UserDetail />
      </ErrorBoundary>
    </Suspense>
  )
}

When selectedUserIdAtom changes, userDataAtom re-fetches and the component suspends again during the fetch. This is reactive data fetching without any extra library.

Writable Derived Atoms

Atoms can define both a read and a write function, letting you present a transformed view of underlying state:

const rawDateAtom = atom<Date>(new Date())

const dateStringAtom = atom(
  (get) => get(rawDateAtom).toISOString().slice(0, 10),
  (get, set, value: string) => {
    set(rawDateAtom, new Date(value))
  }
)

function DatePicker() {
  const [dateStr, setDateStr] = useAtom(dateStringAtom)
  return (
    <input
      type="date"
      value={dateStr}
      onChange={(e) => setDateStr(e.target.value)}
    />
  )
}

The component works with a string. The atom handles conversion. The rest of your code works with a Date object.

When to Use Which

SituationBetter choice
Global UI state (sidebar, modals, toasts)Zustand
Auth session, user preferencesZustand with persist middleware
Complex state with related actionsZustand slices
Shared state between sibling componentsEither
State derived from other stateJotai
Async data that follows selected IDs or filtersJotai async atoms
Fine-grained subscriptions to avoid re-rendersJotai
Need Redux DevTools time-travel debuggingZustand with devtools middleware

The libraries aren’t mutually exclusive. A common pattern: Zustand handles the auth session, cart, and major UI flags. Jotai handles per-feature atoms like “which table row is selected” or “what is the search query on this page.” Neither competes with TanStack Query for server state (paginated API responses, mutations, caching), which is a separate concern with its own caching and refetching model.

If you’re coming from a signals-based framework, the atom model in Jotai will feel familiar; Angular’s own move to signals is solving a similar granular-reactivity problem from a different framework’s starting point.

SSR and Hydration Gotchas

Both libraries need extra care in server-rendered apps like Next.js, because a module-level store or atom is shared across requests on the server unless you scope it per request.

For Zustand, the fix is a store factory instead of a single exported store:

// Wrong for SSR: one store instance shared across all requests
export const useCartStore = create<CartStore>((set) => ({ /* ... */ }))

// Right for SSR: a factory that creates a fresh store per request,
// provided through context in the app's root layout
export const createCartStore = () => create<CartStore>((set) => ({ /* ... */ }))

Jotai has the same issue by default, since atoms are module-level singletons. Wrapping the app in a <Provider> from Jotai scopes atom state to that provider’s subtree instead of the whole module, which is the recommended setup for any SSR framework. Skipping this step is a common cause of one user’s cart or preferences leaking into another user’s server-rendered response.

What Redux Still Does Better

Redux Toolkit is still the answer if your team needs:

  • Strict action logging for audit or compliance requirements
  • Time-travel debugging as a primary debugging workflow
  • Tight integration with existing Redux middleware (redux-saga, redux-observable)
  • A pattern that junior developers can look up and find extensive documentation on

Those use cases exist. They’re just not the starting assumption for a new product in 2026.

For most new projects: start with TanStack Query for remote data, Zustand for shared client state, and reach for Jotai when you need fine-grained atoms or Suspense-integrated async state. You probably won’t need all three. Start with one and add the second only when the first doesn’t fit.

Frequently asked questions

Should I use Zustand or Jotai for React state management?
Use Zustand for shared application state with related actions in one place, such as a cart, auth session, or global UI flags. Use Jotai when you need fine-grained atoms, state derived from other state, or Suspense-integrated async data. The two aren't mutually exclusive and are often used together in the same app.
Do Zustand and Jotai replace Redux?
For most new projects, yes — they cover the same shared-state use cases with far less boilerplate and no Provider wrapping required. Redux Toolkit still makes sense when a team specifically needs strict action logging for compliance, time-travel debugging as a primary workflow, or existing Redux middleware like redux-saga.
What does the Zustand selector pattern do?
Calling useCartStore((state) => state.items.length) instead of destructuring the whole store means the component only re-renders when items.length itself changes, not on every store update. This selector pattern is the main tool for controlling re-renders in Zustand.
How does Jotai handle async data?
Jotai lets you define an atom whose value is computed by an async function, and any component reading that atom with useAtomValue automatically suspends while the fetch is in flight — wrapped in a <Suspense> boundary, this gives you reactive async data fetching without a separate library.
Do Zustand or Jotai handle server state like API data?
No. Both are built for client state — UI flags, form state, selections, derived values. Server state such as paginated API responses, caching, and mutations is better handled by a dedicated library like TanStack Query, which is designed around request deduplication, caching, and background refetching.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored