Testing Jotai Atoms: Derived Atoms, Async Atoms, Atom Families, and Suspense
Jotai is a primitive, flexible state management library built on atoms. Each atom is an independent piece of state, and atoms can derive from other atoms — synchronously or asynchronously. Testing Jotai requires understanding how to provide atom state in tests and how to handle async atoms with Suspense.
Setup
npm install jotai
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domJotai provides createStore for test isolation. Use it to create a fresh store per test.
Testing Primitive Atoms
// atoms/userAtom.ts
import { atom } from 'jotai'
export interface User {
id: string
name: string
email: string
plan: 'free' | 'pro'
}
export const userAtom = atom<User | null>(null)
export const isAuthenticatedAtom = atom<boolean>((get) => get(userAtom) !== null)Test atoms directly using createStore:
// atoms/userAtom.test.ts
import { createStore } from 'jotai'
import { describe, it, expect } from 'vitest'
import { userAtom, isAuthenticatedAtom } from './userAtom'
function createTestStore() {
return createStore()
}
describe('userAtom', () => {
it('initializes as null', () => {
const store = createTestStore()
expect(store.get(userAtom)).toBeNull()
})
it('stores user correctly', () => {
const store = createTestStore()
const user = { id: 'u1', name: 'Alice Chen', email: 'alice@example.com', plan: 'pro' as const }
store.set(userAtom, user)
expect(store.get(userAtom)?.name).toBe('Alice Chen')
})
it('isAuthenticatedAtom is false when user is null', () => {
const store = createTestStore()
expect(store.get(isAuthenticatedAtom)).toBe(false)
})
it('isAuthenticatedAtom is true when user is set', () => {
const store = createTestStore()
store.set(userAtom, { id: 'u1', name: 'Alice', email: 'a@b.com', plan: 'free' })
expect(store.get(isAuthenticatedAtom)).toBe(true)
})
})Testing Derived Atoms
Derived atoms read from other atoms. Test them by setting up the dependency atoms first:
// atoms/cartAtom.ts
import { atom } from 'jotai'
export interface CartItem {
id: string
name: string
price: number
quantity: number
}
export const cartItemsAtom = atom<CartItem[]>([])
export const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom)
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
export const cartCountAtom = atom((get) => {
return get(cartItemsAtom).reduce((sum, item) => sum + item.quantity, 0)
})
export const hasItemAtom = (itemId: string) =>
atom((get) => get(cartItemsAtom).some((item) => item.id === itemId))// atoms/cartAtom.test.ts
import { createStore } from 'jotai'
import { describe, it, expect } from 'vitest'
import { cartItemsAtom, cartTotalAtom, cartCountAtom, hasItemAtom } from './cartAtom'
describe('derived cart atoms', () => {
it('cartTotalAtom returns 0 for empty cart', () => {
const store = createStore()
expect(store.get(cartTotalAtom)).toBe(0)
})
it('cartTotalAtom sums prices with quantities', () => {
const store = createStore()
store.set(cartItemsAtom, [
{ id: 'p1', name: 'Headphones', price: 79.99, quantity: 2 },
{ id: 'p2', name: 'Hub', price: 39.99, quantity: 1 },
])
expect(store.get(cartTotalAtom)).toBeCloseTo(199.97, 2)
})
it('cartCountAtom sums all quantities', () => {
const store = createStore()
store.set(cartItemsAtom, [
{ id: 'p1', name: 'A', price: 10, quantity: 3 },
{ id: 'p2', name: 'B', price: 20, quantity: 2 },
])
expect(store.get(cartCountAtom)).toBe(5)
})
it('hasItemAtom returns true when item is in cart', () => {
const store = createStore()
store.set(cartItemsAtom, [
{ id: 'p1', name: 'A', price: 10, quantity: 1 },
])
const checkP1 = hasItemAtom('p1')
const checkP2 = hasItemAtom('p2')
expect(store.get(checkP1)).toBe(true)
expect(store.get(checkP2)).toBe(false)
})
})Testing Async Atoms
Async atoms return promises. Test them using Suspense and findBy* queries:
// atoms/userDataAtom.ts
import { atom } from 'jotai'
export const userIdAtom = atom<string | null>(null)
export const userDataAtom = atom(async (get) => {
const userId = get(userIdAtom)
if (!userId) return null
const res = await fetch(`/api/users/${userId}`)
if (!res.ok) throw new Error('Failed to fetch user')
return res.json()
})// atoms/userDataAtom.test.tsx
import { render, screen } from '@testing-library/react'
import { Provider, useAtom, useAtomValue } from 'jotai'
import { createStore } from 'jotai'
import { Suspense } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { userIdAtom, userDataAtom } from './userDataAtom'
function UserProfile() {
const userData = useAtomValue(userDataAtom)
if (!userData) return <p>No user selected</p>
return <p>{userData.name}</p>
}
function TestWrapper({ store }: { store: ReturnType<typeof createStore> }) {
return (
<Provider store={store}>
<Suspense fallback={<p>Loading…</p>}>
<UserProfile />
</Suspense>
</Provider>
)
}
describe('userDataAtom async atom', () => {
beforeEach(() => {
global.fetch = vi.fn()
})
it('shows no user when userId is null', async () => {
const store = createStore()
render(<TestWrapper store={store} />)
expect(await screen.findByText('No user selected')).toBeInTheDocument()
})
it('fetches and displays user when userId is set', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: 'u1', name: 'Alice Chen' }),
} as Response)
const store = createStore()
store.set(userIdAtom, 'u1')
render(<TestWrapper store={store} />)
// Loading state first
expect(screen.getByText('Loading…')).toBeInTheDocument()
// Then the data
expect(await screen.findByText('Alice Chen')).toBeInTheDocument()
})
})Testing Atom Families
Atom families create atoms dynamically based on a parameter:
// atoms/productAtom.ts
import { atomFamily } from 'jotai/utils'
import { atom } from 'jotai'
export interface Product {
id: string
name: string
stock: number
}
export const productAtomFamily = atomFamily((productId: string) =>
atom<Product | null>(null)
)
export const isInStockAtomFamily = atomFamily((productId: string) =>
atom((get) => {
const product = get(productAtomFamily(productId))
return (product?.stock ?? 0) > 0
})
)// atoms/productAtom.test.ts
import { createStore } from 'jotai'
import { describe, it, expect, afterEach } from 'vitest'
import { productAtomFamily, isInStockAtomFamily } from './productAtom'
describe('productAtomFamily', () => {
afterEach(() => {
// Clear atom family cache between tests
productAtomFamily.setShouldRemove(() => true)
productAtomFamily.setShouldRemove(null)
})
it('creates independent atoms per product ID', () => {
const store = createStore()
store.set(productAtomFamily('p1'), { id: 'p1', name: 'Headphones', stock: 5 })
store.set(productAtomFamily('p2'), { id: 'p2', name: 'Hub', stock: 0 })
expect(store.get(productAtomFamily('p1'))?.name).toBe('Headphones')
expect(store.get(productAtomFamily('p2'))?.name).toBe('Hub')
})
it('isInStockAtomFamily returns true for in-stock products', () => {
const store = createStore()
store.set(productAtomFamily('p1'), { id: 'p1', name: 'Headphones', stock: 5 })
store.set(productAtomFamily('p2'), { id: 'p2', name: 'Hub', stock: 0 })
expect(store.get(isInStockAtomFamily('p1'))).toBe(true)
expect(store.get(isInStockAtomFamily('p2'))).toBe(false)
})
it('returns false for product with null state', () => {
const store = createStore()
expect(store.get(isInStockAtomFamily('unknown'))).toBe(false)
})
})Testing Writable Derived Atoms
Write actions into derived atoms using the setter:
// atoms/themeAtom.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
export const themeAtom = atomWithStorage<'light' | 'dark' | 'system'>('theme', 'system')
export const resolvedThemeAtom = atom(
(get) => {
const theme = get(themeAtom)
if (theme !== 'system') return theme
// Resolve system preference (in tests, defaults to 'light')
return typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
},
(_get, set, newTheme: 'light' | 'dark' | 'system') => {
set(themeAtom, newTheme)
}
)import { createStore } from 'jotai'
import { describe, it, expect } from 'vitest'
import { themeAtom, resolvedThemeAtom } from './themeAtom'
describe('themeAtom', () => {
it('defaults to system', () => {
const store = createStore()
expect(store.get(themeAtom)).toBe('system')
})
it('resolves to light in test environment (no dark mode system pref)', () => {
const store = createStore()
expect(store.get(resolvedThemeAtom)).toBe('light')
})
it('can be set to dark explicitly', () => {
const store = createStore()
store.set(resolvedThemeAtom, 'dark')
expect(store.get(themeAtom)).toBe('dark')
expect(store.get(resolvedThemeAtom)).toBe('dark')
})
})Component Integration Testing
Use the Provider with a custom store to isolate state between tests:
// components/ThemeToggle.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Provider } from 'jotai'
import { createStore } from 'jotai'
import { describe, it, expect } from 'vitest'
import { themeAtom } from '@/atoms/themeAtom'
import { ThemeToggle } from './ThemeToggle'
function renderWithStore(
ui: React.ReactNode,
{ store = createStore() } = {}
) {
return render(<Provider store={store}>{ui}</Provider>)
}
describe('ThemeToggle', () => {
it('shows current theme', () => {
const store = createStore()
store.set(themeAtom, 'dark')
renderWithStore(<ThemeToggle />, { store })
expect(screen.getByText('Dark mode')).toBeInTheDocument()
})
it('toggles theme on click', async () => {
const store = createStore()
store.set(themeAtom, 'light')
renderWithStore(<ThemeToggle />, { store })
await userEvent.click(screen.getByRole('button', { name: /toggle theme/i }))
expect(store.get(themeAtom)).toBe('dark')
})
})What Automated Tests Miss
Jotai store tests cover atom logic, but not:
- Atom dependency cascades — when deeply nested atoms update, render counts
- DevTools integration — Jotai DevTools inspection
- Storage persistence —
atomWithStorageand localStorage edge cases - SSR hydration — atom values on server vs client
HelpMeTest schedules full browser tests against your deployed app, catching state-related UI regressions that unit tests miss.
Summary
Testing Jotai:
- Primitive atoms →
createStore(), usestore.get()andstore.set()directly - Derived atoms → set dependencies first via
store.set(), then read derived value - Async atoms → wrap in
<Provider store={store}><Suspense>in component tests; usefindBy* - Atom families → create fresh stores per test; clear family cache in
afterEach - Components → wrap with
<Provider store={store}>wherestoreis freshly created per test
Fresh stores per test is the key discipline — Jotai atoms share state when they share a store.