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.
Table of Contents
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 model | Atoms (bottom-up) | Store (top-down) |
| Selectors | Not needed — atoms are already granular | Needed for performance |
| Async state | First-class with Suspense | Manual (actions + loading state) |
| Outside React | Via store.get/set | getState().action() |
| Best for | Fine-grained UI state, async data | Shared app state, actions |