Jotai manages React state as independent atoms — components subscribe only to the atoms they use, so unrelated state changes never trigger re-renders. atom(initialValue) creates a primitive atom; atom(get => computation) creates a derived read-only atom; atom(get, (get, set) => setter) creates a writable derived atom. useAtom returns [value, setter]; useAtomValue for read-only; useSetAtom for write-only. atomWithStorage syncs an atom to localStorage or sessionStorage. atomWithReducer implements the reducer pattern for event-driven state. atomFamily creates parameterized atoms indexed by a key — perfect for per-entity state. atomWithQuery from jotai-tanstack-query integrates async data fetching. createStore runs Jotai outside React for testing or SSR. Claude Code generates Jotai atom definitions, derived atoms, async patterns, and the atom family configurations for fine-grained subscription in React applications.
CLAUDE.md for Jotai
## Jotai Stack
- Version: jotai >= 2.10, jotai-devtools >= 0.10
- Primitive: const cartAtom = atom<CartItem[]>([])
- Derived read: const totalAtom = atom(get => get(cartAtom).reduce(...))
- Writable derived: atom(get, (get, set, val) => { set(baseAtom, transform(val)) })
- Async: atom(async get => { const id = get(userIdAtom); return fetch(..) }) — suspense
- Storage: atomWithStorage("key", defaultValue) — localStorage/sessionStorage
- Family: atomFamily(id => atom(async get => fetchOrder(id))) — per-ID atoms
- Integration: jotai-tanstack-query — atomWithQuery, atomWithInfiniteQuery
- SSR: const store = createStore() — hydrate before render
Atom Definitions
// atoms/order-atoms.ts — atomic state for orders
import { atom } from "jotai"
import { atomWithStorage, atomWithReducer } from "jotai/utils"
import { atomFamily } from "jotai/utils"
// Auth atoms
export const userIdAtom = atom<string | null>(null)
export const isAuthenticatedAtom = atom(get => get(userIdAtom) !== null)
// Orders list — filters
export const statusFilterAtom = atom<"all" | "pending" | "shipped" | "delivered">("all")
export const searchQueryAtom = atom("")
export const pageAtom = atom(1)
// Reset page when filter changes
export const statusFilterWithResetAtom = atom(
get => get(statusFilterAtom),
(_get, set, newStatus: typeof statusFilterAtom extends ReturnType<typeof atom<infer T>> ? T : never) => {
set(statusFilterAtom, newStatus)
set(pageAtom, 1) // Reset to page 1 on filter change
}
)
// Cart atoms
interface CartItem {
productId: string
name: string
priceCents: number
quantity: number
}
export const cartItemsAtom = atomWithStorage<CartItem[]>("cart-items", [])
// Derived cart totals
export const cartTotalAtom = atom(get =>
get(cartItemsAtom).reduce((sum, item) => sum + item.priceCents * item.quantity, 0)
)
export const cartCountAtom = atom(get =>
get(cartItemsAtom).reduce((sum, item) => sum + item.quantity, 0)
)
// Writable derived: add item with quantity merge
export const addToCartAtom = atom(
null,
(get, set, item: Omit<CartItem, "quantity">) => {
set(cartItemsAtom, currentItems => {
const existing = currentItems.find(i => i.productId === item.productId)
if (existing) {
return currentItems.map(i =>
i.productId === item.productId
? { ...i, quantity: i.quantity + 1 }
: i
)
}
return [...currentItems, { ...item, quantity: 1 }]
})
}
)
export const removeFromCartAtom = atom(
null,
(_get, set, productId: string) => {
set(cartItemsAtom, items => items.filter(i => i.productId !== productId))
}
)
export const clearCartAtom = atom(null, (_get, set) => {
set(cartItemsAtom, [])
})
Async Atoms with Suspense
// atoms/async-atoms.ts — async data fetching
import { atom } from "jotai"
import { userIdAtom, statusFilterAtom, pageAtom, searchQueryAtom } from "./order-atoms"
interface Order {
id: string
status: string
totalCents: number
createdAt: string
}
// Async atom — suspends while loading
export const ordersAtom = atom(async get => {
const userId = get(userIdAtom)
if (!userId) return { orders: [], totalCount: 0 }
const status = get(statusFilterAtom)
const search = get(searchQueryAtom)
const page = get(pageAtom)
const params = new URLSearchParams({
customerId: userId,
page: String(page),
limit: "20",
...(status !== "all" && { status }),
...(search && { search }),
})
const response = await fetch(`/api/orders?${params}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<{ orders: Order[]; totalCount: number }>
})
// Single order by ID — per-entity with atomFamily
export const orderAtomFamily = atomFamily((orderId: string) =>
atom(async () => {
const response = await fetch(`/api/orders/${orderId}`)
if (!response.ok) throw new Error(`Order ${orderId} not found`)
return response.json() as Promise<Order>
})
)
atomWithReducer
// atoms/checkout-atom.ts — reducer pattern for checkout state
import { atomWithReducer } from "jotai/utils"
type CheckoutState =
| { step: "cart" }
| { step: "shipping"; cartTotal: number }
| { step: "payment"; shippingAddress: Address; cartTotal: number }
| { step: "confirming"; paymentMethodId: string; shippingAddress: Address; cartTotal: number }
| { step: "success"; orderId: string }
| { step: "error"; message: string }
type CheckoutAction =
| { type: "PROCEED_TO_SHIPPING"; cartTotal: number }
| { type: "ADD_SHIPPING_ADDRESS"; address: Address }
| { type: "ADD_PAYMENT_METHOD"; paymentMethodId: string }
| { type: "ORDER_CONFIRMED"; orderId: string }
| { type: "ERROR"; message: string }
| { type: "BACK" }
| { type: "RESET" }
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
switch (action.type) {
case "PROCEED_TO_SHIPPING":
return { step: "shipping", cartTotal: action.cartTotal }
case "ADD_SHIPPING_ADDRESS":
if (state.step !== "shipping") return state
return { step: "payment", shippingAddress: action.address, cartTotal: state.cartTotal }
case "ADD_PAYMENT_METHOD":
if (state.step !== "payment") return state
return {
step: "confirming",
paymentMethodId: action.paymentMethodId,
shippingAddress: state.shippingAddress,
cartTotal: state.cartTotal,
}
case "ORDER_CONFIRMED":
return { step: "success", orderId: action.orderId }
case "ERROR":
return { step: "error", message: action.message }
case "BACK":
if (state.step === "payment") return { step: "shipping", cartTotal: state.cartTotal }
if (state.step === "shipping") return { step: "cart" }
return { step: "cart" }
case "RESET":
return { step: "cart" }
default:
return state
}
}
export const checkoutAtom = atomWithReducer<CheckoutState, CheckoutAction>(
{ step: "cart" },
checkoutReducer
)
React Components
// components/CartButton.tsx — write-only atom subscription
import { useSetAtom, useAtomValue } from "jotai"
import { addToCartAtom, cartCountAtom } from "@/atoms/order-atoms"
export function AddToCartButton({ product }: { product: Product }) {
const addToCart = useSetAtom(addToCartAtom) // Only subscribes to setter — no re-renders on count change
const cartCount = useAtomValue(cartCountAtom)
return (
<button
onClick={() => addToCart({
productId: product.id,
name: product.name,
priceCents: product.priceCents,
})}
className="btn btn-primary"
>
Add to Cart {cartCount > 0 && <span className="badge">{cartCount}</span>}
</button>
)
}
// components/OrdersPage.tsx — async atom with Suspense
import { Suspense } from "react"
import { useAtomValue, useSetAtom } from "jotai"
import { ordersAtom, statusFilterWithResetAtom } from "@/atoms"
function OrdersList() {
// Suspends until ordersAtom resolves
const { orders, totalCount } = useAtomValue(ordersAtom)
const setStatus = useSetAtom(statusFilterWithResetAtom)
return (
<div>
<p>{totalCount} orders</p>
{orders.map(order => <OrderCard key={order.id} order={order} />)}
</div>
)
}
export function OrdersPage() {
return (
<ErrorBoundary fallback={<p>Failed to load orders</p>}>
<Suspense fallback={<OrdersSkeleton />}>
<OrdersList />
</Suspense>
</ErrorBoundary>
)
}
For the Zustand alternative that uses a single store with slices rather than individual atoms — better for teams preferring one centralized state store with explicit action functions, or when state needs to be accessed outside React components, see the Zustand Advanced guide for the slice and middleware patterns. For the TanStack Query alternative when the majority of state is server state (asynchronous fetched data) rather than client-side application state — Jotai works well alongside TanStack Query with atoms holding only UI/client state, see the TanStack Query Advanced guide for server state patterns. The Claude Skills 360 bundle includes Jotai skill sets covering atoms, derived state, and async patterns. Start with the free tier to try Jotai atom generation.