Jotai advanced patterns go beyond basic atoms — atomWithStorage(key, initialValue) persists to localStorage with automatic sync. atomWithQuery((get) => ({ queryKey, queryFn })) integrates React Query into Jotai. atom((get, { signal }) => fetch(url, { signal })) builds abort-safe async atoms. atomWithReset(initialValue) returns a [atom, resetAtom] pair. atomFamily((id) => atom(fetchPost(id))) creates parameterized atoms. loadable(asyncAtom) converts a suspenseful atom to { state: "loading" | "hasData" | "hasError" }. selectAtom(sourceAtom, (v) => v.slice(0, 5)) creates cheap derived atoms. atom(null, (get, set, update) => { set(countAtom, get(countAtom) + update) }) creates write-only atoms. onMount: (set) => { const unsub = ws.subscribe(...); return unsub } runs effects. splitAtom(listAtom) enables efficient per-item atoms. Provider with store creates scoped atom stores. Claude Code generates Jotai atom stores, async query atoms, persistence patterns, and complex derived state.
CLAUDE.md for Jotai Advanced
## Jotai Advanced Stack
- Version: jotai >= 2.9, jotai-tanstack-query >= 0.8 (for atomWithQuery)
- Storage: import { atomWithStorage } from "jotai/utils" — syncs to localStorage automatically
- Async: atom(async (get) => { const id = get(userIdAtom); return fetchUser(id) }) — use with Suspense
- Loadable: import { loadable } from "jotai/utils"; const loadable = loadable(asyncAtom) — no Suspense needed
- Family: import { atomFamily } from "jotai/utils"; const postAtom = atomFamily((id: string) => atom(fetchPost(id)))
- Reset: import { atomWithReset, useResetAtom } from "jotai/utils"
- Select: import { selectAtom } from "jotai/utils"; const nameAtom = selectAtom(userAtom, u => u.name)
- Split: import { splitAtom } from "jotai/utils"; const itemAtomsAtom = splitAtom(itemsAtom)
Core Atom Definitions
// store/atoms.ts — Jotai atom store
import { atom } from "jotai"
import {
atomWithStorage,
atomWithReset,
atomFamily,
loadable,
selectAtom,
splitAtom,
atomWithRefresh,
} from "jotai/utils"
import { focusAtom } from "jotai-optics"
// ── Auth atoms ─────────────────────────────────────────────────────────────
export type AuthUser = {
id: string
email: string
name: string
role: "user" | "admin"
avatarUrl: string | null
}
// Persisted across page reloads
export const authTokenAtom = atomWithStorage<string | null>("auth_token", null)
// Derived: is user authenticated?
export const isAuthenticatedAtom = atom((get) => get(authTokenAtom) !== null)
// User profile — loaded based on token
export const currentUserAtom = atom(async (get) => {
const token = get(authTokenAtom)
if (!token) return null
const res = await fetch("/api/users/me", {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return null
return res.json() as Promise<AuthUser>
})
// Loadable version for components that can't use Suspense
export const currentUserLoadableAtom = loadable(currentUserAtom)
// Select sub-fields without re-rendering on unrelated changes
export const userRoleAtom = selectAtom(
currentUserAtom,
(user) => user?.role ?? null,
)
// ── Cart atoms ─────────────────────────────────────────────────────────────
export type CartItem = {
id: string
productId: string
name: string
price: number
quantity: number
imageUrl: string | null
}
export const cartItemsAtom = atomWithStorage<CartItem[]>("cart_v2", [])
// Derived cart stats
export const cartCountAtom = selectAtom(
cartItemsAtom,
(items) => items.reduce((sum, item) => sum + item.quantity, 0),
)
export const cartTotalAtom = selectAtom(
cartItemsAtom,
(items) => items.reduce((sum, item) => sum + item.price * item.quantity, 0),
)
// Write atom: add to cart
export const addToCartAtom = atom(null, (get, set, item: CartItem) => {
const items = get(cartItemsAtom)
const existing = items.find(i => i.productId === item.productId)
if (existing) {
set(cartItemsAtom, items.map(i =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i,
))
} else {
set(cartItemsAtom, [...items, item])
}
})
// Write atom: remove from cart
export const removeFromCartAtom = atom(null, (get, set, productId: string) => {
set(cartItemsAtom, get(cartItemsAtom).filter(i => i.productId !== productId))
})
// Write atom: update quantity
export const updateQuantityAtom = atom(
null,
(get, set, { productId, quantity }: { productId: string; quantity: number }) => {
if (quantity <= 0) {
set(cartItemsAtom, get(cartItemsAtom).filter(i => i.productId !== productId))
} else {
set(cartItemsAtom, get(cartItemsAtom).map(i =>
i.productId === productId ? { ...i, quantity } : i,
))
}
},
)
// Resettable atom for empty cart
export const [cartAtom, resetCartAtom] = atomWithReset<CartItem[]>([])
// ── Per-item atoms with atomFamily ─────────────────────────────────────────
export type Post = {
id: string
title: string
content: string
liked: boolean
bookmarked: boolean
}
// One atom per post ID — cached automatically
export const postAtomFamily = atomFamily((postId: string) =>
atom(async () => {
const res = await fetch(`/api/posts/${postId}`)
if (!res.ok) throw new Error("Post not found")
return res.json() as Promise<Post>
}),
)
// Search state with debounced fetch
export const searchQueryAtom = atom("")
export const searchResultsAtom = atom(async (get) => {
const query = get(searchQueryAtom)
if (query.length < 2) return []
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
return res.json() as Promise<Post[]>
})
export const searchResultsLoadableAtom = loadable(searchResultsAtom)
// ── Settings with nested focus ─────────────────────────────────────────────
type AppSettings = {
theme: "light" | "dark" | "system"
language: string
notifications: {
email: boolean
push: boolean
sms: boolean
}
sidebar: {
collapsed: boolean
width: number
}
}
export const settingsAtom = atomWithStorage<AppSettings>("app_settings_v2", {
theme: "system",
language: "en",
notifications: { email: true, push: true, sms: false },
sidebar: { collapsed: false, width: 280 },
})
// Focus atoms for nested sub-trees
export const themeAtom = focusAtom(settingsAtom, (o) => o.prop("theme"))
export const notificationsAtom = focusAtom(settingsAtom, (o) => o.prop("notifications"))
export const sidebarAtom = focusAtom(settingsAtom, (o) => o.prop("sidebar"))
React Components with Jotai Hooks
// components/cart/CartDrawer.tsx — Jotai cart with split atoms
"use client"
import {
useAtom,
useAtomValue,
useSetAtom,
} from "jotai"
import {
cartItemsAtom,
cartCountAtom,
cartTotalAtom,
addToCartAtom,
removeFromCartAtom,
updateQuantityAtom,
resetCartAtom,
type CartItem,
} from "@/store/atoms"
import { splitAtom } from "jotai/utils"
import { useMemo } from "react"
// Split list atom into per-item atoms for efficient rendering
const cartItemAtomsAtom = splitAtom(cartItemsAtom)
function CartItemRow({ itemAtom }: { itemAtom: ReturnType<typeof splitAtom<CartItem>>[number] }) {
const [item] = useAtom(itemAtom)
const updateQuantity = useSetAtom(updateQuantityAtom)
const removeFromCart = useSetAtom(removeFromCartAtom)
return (
<div className="flex items-center gap-3 py-3">
{item.imageUrl && (
<img src={item.imageUrl} alt={item.name} className="size-16 rounded-lg object-cover flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.name}</p>
<p className="text-sm text-muted-foreground">${(item.price / 100).toFixed(2)}</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => updateQuantity({ productId: item.productId, quantity: item.quantity - 1 })}
className="size-7 rounded border flex items-center justify-center text-sm">
−
</button>
<span className="w-8 text-center text-sm">{item.quantity}</span>
<button onClick={() => updateQuantity({ productId: item.productId, quantity: item.quantity + 1 })}
className="size-7 rounded border flex items-center justify-center text-sm">
+
</button>
</div>
<button onClick={() => removeFromCart(item.productId)}
className="text-sm text-destructive hover:underline ml-2">
Remove
</button>
</div>
)
}
export function CartDrawer({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const itemAtoms = useAtomValue(cartItemAtomsAtom)
const count = useAtomValue(cartCountAtom)
const total = useAtomValue(cartTotalAtom)
const resetCart = useSetAtom(resetCartAtom)
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="w-96 bg-background border-l shadow-xl flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">Cart ({count})</h2>
<button onClick={onClose}>✕</button>
</div>
<div className="flex-1 overflow-y-auto px-4 divide-y">
{itemAtoms.map((itemAtom) => (
<CartItemRow key={`${itemAtom}`} itemAtom={itemAtom} />
))}
{itemAtoms.length === 0 && (
<p className="text-center text-muted-foreground py-12">Your cart is empty</p>
)}
</div>
{itemAtoms.length > 0 && (
<div className="p-4 border-t space-y-3">
<div className="flex justify-between font-semibold">
<span>Total</span>
<span>${(total / 100).toFixed(2)}</span>
</div>
<button className="w-full py-3 bg-primary text-primary-foreground rounded-xl font-medium">
Checkout
</button>
<button onClick={() => resetCart()} className="w-full text-sm text-muted-foreground hover:underline">
Clear cart
</button>
</div>
)}
</div>
</div>
)
}
For the Zustand alternative when a simpler store-based API (not atomic) with create() and middleware like immer, devtools, and persist is preferred — Zustand has a flatter learning curve and is better for large stores where you want to keep related state together, while Jotai excels at granular invalidation where components re-render only when their specific atoms change, see the Zustand advanced guide. For the Recoil alternative when Facebook’s atomic model with selector dependency tracking, selectorFamily, and atomEffects for cross-cutting concerns across atoms is familiar — Recoil and Jotai have similar philosophies but Jotai has less boilerplate and better TypeScript support, see the Recoil guide. The Claude Skills 360 bundle includes Jotai advanced skill sets covering storage, async atoms, and derived state. Start with the free tier to try atomic state management generation.