Skip to content

Web Development · Frontend

Zustand and Jotai: React State Management Without the Redux Ceremony

Redux is overkill for most React apps. Zustand and Jotai cover 90% of real state management needs with a fraction of the boilerplate. Here's when to use each and how they actually work.

Anurag Verma

Anurag Verma

7 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 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).

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.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored