Jotai: Atomic State Management for React
Most React state problems stem from choosing the wrong abstraction. Jotai's atomic approach solves composition, performance, and testability issues that Context API and Redux can't handle elegantly.
Jotai: Atomic State Management for React
Most React state management problems stem from choosing abstractions that force developers to couple unrelated concerns. Teams reach for Redux for global state, then watch as their codebase fills with boilerplate. They try Context API, then discover performance cliffs when consumers re-render unnecessarily. They adopt Zustand for simplicity, then struggle with derived state and async dependencies.
Jotai solves these problems through atomic state management. The library models state as independent atoms that compose together, eliminating the false choice between global stores and prop drilling. This matters because the abstraction matches how engineers actually think about state: as discrete values that sometimes depend on each other.
Understanding Jotai's Atomic Philosophy
The atomic approach treats each piece of state as a separate unit. An atom represents a single value that components can read and write. Unlike Redux slices or Zustand stores, atoms don't require upfront schema definition or namespace management. Developers create atoms on demand, and Jotai handles dependency tracking automatically.
This distinction is critical. When state lives in a monolithic store, adding a new piece of state means navigating existing structure. When state lives in atoms, adding new state means creating a new atom. The difference compounds across a team of engineers working in parallel.

Jotai atoms compose through derived state. One atom can compute its value from other atoms, and Jotai re-computes only when dependencies change. This enables bottom-up state architecture where complex state emerges from simple primitives. The pattern prevents the fragile coupling that appears when components reach across domain boundaries.
The memory model matters here. Jotai atoms don't hold values globally by default. Instead, atoms hold values within a Provider scope, making state trees predictable and testable. Components outside a Provider see default values, components inside see scoped values. This eliminates the singleton trap that makes global stores difficult to test and reason about.
Creating Your First Atoms: Primitive and Derived State
Start with primitive atoms for independent values. A primitive atom wraps a single piece of data with no dependencies:
import { atom, useAtom } from 'jotai'
// Primitive atoms define independent state
const countAtom = atom(0)
const userAtom = atom<User | null>(null)
const filtersAtom = atom({
category: 'all',
sortBy: 'date',
ascending: true
})
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}Derived atoms compute values from other atoms. Jotai tracks dependencies automatically and re-computes only when necessary:
// Derived atom depends on countAtom
const doubledCountAtom = atom((get) => get(countAtom) * 2)
// Derived atom with multiple dependencies
const filteredItemsAtom = atom((get) => {
const items = get(itemsAtom)
const filters = get(filtersAtom)
return items
.filter(item =>
filters.category === 'all' || item.category === filters.category
)
.sort((a, b) => {
const diff = a[filters.sortBy] - b[filters.sortBy]
return filters.ascending ? diff : -diff
})
})
function ItemList() {
// Component re-renders only when filteredItemsAtom changes
const [items] = useAtom(filteredItemsAtom)
return <ul>{items.map(item => <Item key={item.id} {...item} />)}</ul>
}The implication here is that components subscribe to exactly the state they need. A component reading filteredItemsAtom doesn't re-render when userAtom changes. This granularity eliminates the selector complexity that Redux requires and the re-render problems that Context API creates.
Jotai vs Zustand vs Context API: When to Choose Atomic State
The state management decision comes down to composition patterns and performance characteristics. Context API works for simple cases where re-renders don't matter. Zustand excels when state lives in a single domain with clear boundaries. Jotai shines when state composes across domains or when derived state becomes complex.
Context API forces all consumers to re-render when any part of the context value changes. This limitation makes Context unsuitable for frequently updating state. Developers work around this with multiple contexts or memoization, but both approaches add complexity that Jotai eliminates by design.
Zustand provides excellent ergonomics for single-domain state. The store pattern works well for shopping carts, user preferences, or UI state that doesn't depend on other domains. The failure mode appears when domains need to reference each other. Cross-store dependencies in Zustand require manual subscription management that Jotai handles automatically.
Jotai's atomic composition enables patterns that are awkward in other libraries. Consider a feature that shows filtered products with real-time inventory updates. The feature needs product atoms, filter atoms, inventory atoms, and a derived atom combining all three. In Zustand, this requires careful subscription management. In Jotai, the derived atom handles dependencies automatically:
const productsAtom = atom<Product[]>([])
const inventoryAtom = atom<Map<string, number>>(new Map())
const categoryFilterAtom = atom<string>('all')
const availableProductsAtom = atom((get) => {
const products = get(productsAtom)
const inventory = get(inventoryAtom)
const category = get(categoryFilterAtom)
return products
.filter(p => category === 'all' || p.category === category)
.filter(p => (inventory.get(p.id) ?? 0) > 0)
})This pattern scales to dozens of atoms without added complexity. Each atom remains simple and testable. The composition emerges from usage rather than upfront design.
Building a Real-World Feature: Shopping Cart with Jotai
A shopping cart demonstrates Jotai's strengths: independent atoms for cart items, quantities, and pricing, with derived atoms for totals and validation. The feature needs to handle async operations, optimistic updates, and complex derived state.

Start with primitive atoms for core state:
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// Cart items persist to localStorage
const cartItemsAtom = atomWithStorage<CartItem[]>('cart', [])
// Product catalog loaded async
const productsAtom = atom(async () => {
const response = await fetch('/api/products')
return response.json()
})
// User-specific pricing rules
const pricingRulesAtom = atom<PricingRule[]>([])Derived atoms compute totals and validation:
// Calculate subtotal from cart items
const subtotalAtom = atom((get) => {
const items = get(cartItemsAtom)
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
// Apply pricing rules to subtotal
const totalAtom = atom((get) => {
const subtotal = get(subtotalAtom)
const rules = get(pricingRulesAtom)
return rules.reduce((total, rule) => {
if (rule.type === 'percentage') {
return total * (1 - rule.value)
}
return total - rule.value
}, subtotal)
})
// Validate inventory availability
const cartValidationAtom = atom(async (get) => {
const items = get(cartItemsAtom)
const products = await get(productsAtom)
const errors: string[] = []
for (const item of items) {
const product = products.find(p => p.id === item.productId)
if (!product) {
errors.push(`Product ${item.productId} not found`)
} else if (product.stock < item.quantity) {
errors.push(`Only ${product.stock} units of ${product.name} available`)
}
}
return errors
})Write atoms handle cart updates:
const addToCartAtom = atom(
null,
(get, set, product: Product) => {
const items = get(cartItemsAtom)
const existing = items.find(item => item.productId === product.id)
if (existing) {
set(cartItemsAtom, items.map(item =>
item.productId === product.id
? { ...item, quantity: item.quantity + 1 }
: item
))
} else {
set(cartItemsAtom, [
...items,
{ productId: product.id, quantity: 1, price: product.price }
])
}
}
)
const removeFromCartAtom = atom(
null,
(get, set, productId: string) => {
const items = get(cartItemsAtom)
set(cartItemsAtom, items.filter(item => item.productId !== productId))
}
)Components consume only the atoms they need. The cart total component doesn't re-render when item quantities change, only when the total changes. The validation component doesn't re-render when pricing rules update, only when cart items or inventory changes.
This granularity matters in production. A complex e-commerce app might have dozens of components reading cart state. With Context API or a single Zustand store, all components re-render on every change. With Jotai, only affected components re-render.
Advanced Patterns: Async Atoms and Suspense Integration
Jotai treats async state as a first-class concern. Async atoms integrate with React Suspense, eliminating the loading state management that plagues other solutions. When a component reads an async atom, React suspends until the promise resolves.
The pattern works naturally with derived state. An async atom can depend on other atoms, and Jotai re-fetches only when dependencies change:
// User ID atom determines which data to fetch
const userIdAtom = atom<string | null>(null)
// User data fetches based on ID
const userAtom = atom(async (get) => {
const userId = get(userIdAtom)
if (!userId) return null
const response = await fetch(`/api/users/${userId}`)
return response.json()
})
// User posts depend on user atom
const userPostsAtom = atom(async (get) => {
const user = await get(userAtom)
if (!user) return []
const response = await fetch(`/api/users/${user.id}/posts`)
return response.json()
})
function UserProfile() {
const [user] = useAtom(userAtom)
const [posts] = useAtom(userPostsAtom)
// Component suspends until both atoms resolve
// Re-suspends only when userIdAtom changes
return (
<div>
<h1>{user?.name}</h1>
<PostList posts={posts} />
</div>
)
}Optimistic updates require write atoms that update immediately while async operations complete:
const optimisticLikeAtom = atom(
(get) => get(likeCountAtom),
async (get, set, postId: string) => {
// Immediately increment
const current = get(likeCountAtom)
set(likeCountAtom, current + 1)
try {
// Send to server
await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
} catch (error) {
// Revert on failure
set(likeCountAtom, current)
throw error
}
}
)The error boundary integration means failed promises surface through standard React error boundaries. Developers don't need custom error state management or loading flags. The platform handles it.
Performance Optimization: Granular Re-renders and Atom Splitting
Performance issues in state management stem from components re-rendering when irrelevant state changes. Jotai solves this through atomic subscriptions. Components subscribe to specific atoms, not entire stores. When an atom updates, only subscribers re-render.
The optimization becomes visible in large forms. A traditional approach puts form state in a single object. Any field change triggers a full re-render. The Jotai approach splits each field into an atom:
const emailAtom = atom('')
const passwordAtom = atom('')
const agreedToTermsAtom = atom(false)
// Derived atom for form validity
const formValidAtom = atom((get) => {
const email = get(emailAtom)
const password = get(passwordAtom)
const agreed = get(agreedToTermsAtom)
return email.includes('@') &&
password.length >= 8 &&
agreed
})
function EmailField() {
const [email, setEmail] = useAtom(emailAtom)
// Re-renders only when email changes
return <input value={email} onChange={e => setEmail(e.target.value)} />
}
function SubmitButton() {
const [valid] = useAtom(formValidAtom)
// Re-renders only when validity changes
return <button disabled={!valid}>Submit</button>
}Complex derived state benefits from atom splitting. Instead of one large derived atom, split computations across multiple atoms. Each component reads the most specific atom it needs:
// Split dashboard metrics into separate atoms
const totalRevenueAtom = atom((get) => {
const orders = get(ordersAtom)
return orders.reduce((sum, order) => sum + order.total, 0)
})
const averageOrderValueAtom = atom((get) => {
const orders = get(ordersAtom)
const total = get(totalRevenueAtom)
return orders.length > 0 ? total / orders.length : 0
})
const topProductsAtom = atom((get) => {
const orders = get(ordersAtom)
const counts = new Map<string, number>()
for (const order of orders) {
for (const item of order.items) {
counts.set(item.productId, (counts.get(item.productId) ?? 0) + 1)
}
}
return Array.from(counts.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
})Each metric component subscribes to its specific atom. The revenue component doesn't re-render when top products change. The top products component doesn't re-render when average order value changes. The granularity scales naturally as dashboards grow.
When Jotai Shines and When to Look Elsewhere
Jotai excels in applications with complex derived state, frequent composition across domains, or performance-sensitive interfaces. The atomic model fits naturally when state dependencies form a graph rather than a tree. Teams building data dashboards, collaborative editing tools, or real-time applications find Jotai's patterns align with their mental models.
The library struggles in simple applications where Context API suffices. The atomic abstraction adds complexity that small apps don't need. If the entire application state fits comfortably in a single object and re-render performance doesn't matter, Context API remains the simpler choice.
Zustand remains a better option for applications with clear domain boundaries and minimal cross-domain dependencies. A single Zustand store provides excellent ergonomics when state naturally forms a tree. The decision point comes when domains need to reference each other frequently. That's where Jotai's composition model becomes valuable.
The migration path from other solutions is straightforward. Start by wrapping existing state in atoms. Move derived state to computed atoms. Split atoms as performance profiles identify bott