React Jotai: Atomic State Management Guide (2026)

Jotai takes a bottom-up approach to state management. Instead of one large global store, you define small, independent pieces of state called atoms. Components subscribe only to the atoms they use — no selectors needed, no unnecessary re-renders. Derived atoms compose atoms together with computed values, and async atoms integrate with Suspense out of the box.

Setup and Basic Atoms

npm install jotai
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

// Define atoms — outside components, at module level
const countAtom = atom(0)
const nameAtom = atom('Alice')
const darkModeAtom = atom(false)

function Counter() {
  // useAtom: returns [value, setter] — like useState
  const [count, setCount] = useAtom(countAtom)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  )
}

// Read-only — no re-render when atom is set (only when value changes)
function CountDisplay() {
  const count = useAtomValue(countAtom)
  return <span>{count}</span>
}

// Write-only — no re-render when atom value changes
function IncrementButton() {
  const setCount = useSetAtom(countAtom)
  return <button onClick={() => setCount(c => c + 1)}>Increment</button>
}
No Provider needed by default: Jotai uses a global store by default — atoms work without any Provider. Use Provider from jotai to create isolated scopes (e.g., per-widget state in a dashboard).

Derived Atoms

Read-only derived atoms compute from other atoms — like computed properties:

import { atom } from 'jotai'

const cartItemsAtom = atom([
  { id: '1', name: 'Widget', qty: 2, price: 9.99 },
  { id: '2', name: 'Gadget', qty: 1, price: 24.99 },
])

// Derived — recalculates when cartItemsAtom changes
const cartTotalAtom = atom(
  (get) => get(cartItemsAtom).reduce((sum, item) => sum + item.price * item.qty, 0)
)

const cartCountAtom = atom(
  (get) => get(cartItemsAtom).reduce((sum, item) => sum + item.qty, 0)
)

const isCartEmptyAtom = atom(
  (get) => get(cartItemsAtom).length === 0
)

// Chain derivations
const formattedTotalAtom = atom(
  (get) => `$${get(cartTotalAtom).toFixed(2)}`
)

// Use in components — subscribes only to relevant atoms
function CartBadge() {
  const count = useAtomValue(cartCountAtom)
  return <span className="badge">{count}</span>
}

function CartTotal() {
  const total = useAtomValue(formattedTotalAtom)
  return <p>Total: {total}</p>
}

Writable Derived Atoms

Writable derived atoms have both a read and a write function:

const cartAtom = atom([])

// Derived atom with custom write logic
const addToCartAtom = atom(
  (get) => get(cartAtom),   // Read: return current cart
  (get, set, newItem) => {  // Write: add item or increment qty
    const cart = get(cartAtom)
    const existing = cart.find(i => i.id === newItem.id)

    if (existing) {
      set(cartAtom, cart.map(i =>
        i.id === newItem.id ? { ...i, qty: i.qty + 1 } : i
      ))
    } else {
      set(cartAtom, [...cart, { ...newItem, qty: 1 }])
    }
  }
)

function AddToCartButton({ product }) {
  const [, addToCart] = useAtom(addToCartAtom)
  return <button onClick={() => addToCart(product)}>Add to Cart</button>
}

// Write-only atom (no read needed)
const clearCartAtom = atom(null, (get, set) => {
  set(cartAtom, [])
})

function ClearCartButton() {
  const clearCart = useSetAtom(clearCartAtom)
  return <button onClick={clearCart}>Clear Cart</button>
}

Async Atoms

Atoms can be async — they integrate with Suspense automatically:

import { atom, useAtomValue } from 'jotai'
import { Suspense } from 'react'

// Async read atom — suspends while fetching
const userIdAtom = atom('user-123')

const userAtom = atom(async (get) => {
  const userId = get(userIdAtom)
  const res = await fetch(`/api/users/${userId}`)
  return res.json()
})

// Component suspends while userAtom resolves
function UserInfo() {
  const user = useAtomValue(userAtom)  // Never undefined here — Suspense handles loading
  return <div>{user.name}</div>
}

function UserPage() {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserInfo />
    </Suspense>
  )
}

// Async write atom — perform side effects
const saveUserAtom = atom(
  null,
  async (get, set, updates) => {
    const currentUser = await get(userAtom)
    const updated = await fetch(`/api/users/${currentUser.id}`, {
      method: 'PATCH',
      body: JSON.stringify(updates),
    }).then(r => r.json())

    // Invalidate the cache by resetting a refresh counter
    set(refreshAtom, c => c + 1)
    return updated
  }
)

Atom Families

Create parameterized atoms — one atom per unique key:

import { atomFamily } from 'jotai/utils'

// One atom per todo ID
const todoAtomFamily = atomFamily((id: string) =>
  atom({ id, text: '', done: false })
)

// One async atom per product ID
const productAtomFamily = atomFamily((id: string) =>
  atom(async () => {
    const res = await fetch(`/api/products/${id}`)
    return res.json()
  })
)

function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))

  return (
    <div>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => setTodo(t => ({ ...t, done: !t.done }))}
      />
      <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
        {todo.text}
      </span>
    </div>
  )
}

// Cleanup when no longer needed
todoAtomFamily.remove('old-id')

Utility Atoms

import { atomWithStorage, atomWithReset, atomWithReducer } from 'jotai/utils'

// Persists to localStorage automatically
const themeAtom = atomWithStorage('theme', 'dark')
const sidebarOpenAtom = atomWithStorage('sidebar', true)

// Reset to initial value
const filterAtom = atomWithReset({ category: '', sort: 'newest' })

function FilterPanel() {
  const [filter, setFilter] = useAtom(filterAtom)
  const resetFilter = useSetAtom(RESET)  // import RESET from 'jotai/utils'

  return (
    <div>
      <select value={filter.sort} onChange={e => setFilter(f => ({ ...f, sort: e.target.value }))}>
        <option value="newest">Newest</option>
        <option value="price">Price</option>
      </select>
      <button onClick={() => resetFilter()}>Reset Filters</button>
    </div>
  )
}

// Reducer-style atom
const cartAtom = atomWithReducer({ items: [] }, (state, action) => {
  switch (action.type) {
    case 'ADD': return { items: [...state.items, action.payload] }
    case 'CLEAR': return { items: [] }
    default: return state
  }
})

TypeScript

import { atom, PrimitiveAtom, WritableAtom } from 'jotai'

interface User {
  id: string
  name: string
  email: string
}

// Typed primitive atom
const userAtom: PrimitiveAtom<User | null> = atom<User | null>(null)

// Typed derived atom
const isLoggedInAtom = atom<boolean>((get) => get(userAtom) !== null)

// Typed async atom
const profileAtom = atom<Promise<User>>(async (get) => {
  const userId = get(currentUserIdAtom)
  return fetch(`/api/users/${userId}`).then(r => r.json())
})

// useAtomValue infers types
const user = useAtomValue(userAtom)       // User | null
const isLoggedIn = useAtomValue(isLoggedInAtom)  // boolean

DevTools

import { useAtomsDevtools } from 'jotai-devtools'
import { Provider } from 'jotai'

// Enable Redux DevTools integration
function AtomsDevtools({ children }) {
  useAtomsDevtools('MyApp')
  return children
}

function App() {
  return (
    <Provider>
      <AtomsDevtools>
        <Router />
      </AtomsDevtools>
    </Provider>
  )
}

// Label atoms for DevTools visibility
const countAtom = atom(0)
countAtom.debugLabel = 'Counter'

const cartAtom = atom([])
cartAtom.debugLabel = 'ShoppingCart'

Jotai vs Zustand

Aspect Jotai Zustand
Mental modelAtoms (bottom-up)Store (top-down)
SelectorsNot needed — atoms are already granularNeeded for performance
Async stateFirst-class with SuspenseManual (actions + loading state)
Outside ReactVia store.get/setgetState().action()
Best forFine-grained UI state, async dataShared app state, actions