Testing Jotai Atoms: Derived Atoms, Async Atoms, Atom Families, and Suspense

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-dom

Jotai 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 persistenceatomWithStorage and 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 atomscreateStore(), use store.get() and store.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; use findBy*
  • Atom families → create fresh stores per test; clear family cache in afterEach
  • Components → wrap with <Provider store={store}> where store is freshly created per test

Fresh stores per test is the key discipline — Jotai atoms share state when they share a store.

Read more