← All posts
·16 min read

Claude Code with Jotai: Atoms, Derived State, and Async Patterns

Claude CodeJotaiReactState Management
Claude Code with Jotai: Atoms, Derived State, and Async Patterns

Why Jotai needs a project-specific CLAUDE.md

Jotai takes a different approach to state than Zustand or Redux. Instead of a single store that holds everything, you compose state from individual atoms. Components subscribe to only the atoms they need, derived atoms compute values from other atoms, and re-renders propagate only through the dependency graph. The model is elegant and the performance characteristics are excellent. The API surface is also small enough that Claude Code appears to know it well.

The appearance is misleading. Claude knows the Jotai v1 API confidently, is familiar with the v2 API, and mixes patterns from both depending on what its training data contained. In practice this produces four categories of failure that do not surface as errors until you are already in a debugging session.

First, provider misconfiguration. Jotai does not require a Provider by default, because the global store is created automatically. The moment you need isolated atom state per component tree, for testing or multi-tenant rendering, you need an explicit Provider. Claude often omits it when it is required and adds it when it is not, because neither choice produces a runtime error immediately.

Second, async atom misuse. In Jotai v2, get inside a derived atom's read function does not resolve promises. If you call get on an async atom, you get a Promise, not the resolved value. The correct pattern is to use await get(asyncAtom) inside an async read function. Claude frequently generates the synchronous version, which returns a Promise object where a value is expected.

Third, atom family key instability. atomFamily takes a parameter and returns an atom. The parameter becomes the cache key using reference equality by default. Passing an object literal as the key creates a new atom on every render. Claude generates object keys by default because they are the most readable in examples. In production code they are a memory leak.

Fourth, jotai-effect misuse. The atomEffect utility from jotai-effect runs a side effect when an atom changes. Claude frequently uses it as a replacement for derived atoms when a simple atom(get => ...) would be cleaner and more composable. Side effects belong in atomEffect. Derived values belong in derived atoms. Without explicit guidance Claude blurs the boundary.

This guide covers the CLAUDE.md rules and atom patterns that prevent these failures. If you are setting up Claude Code for the first time, the Claude Code setup guide covers installation and project authentication. For broader React conventions that pair with Jotai, the Claude Code with React guide covers component and rendering patterns.

The Jotai CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For Jotai, it needs to encode: which version, where atoms live, how derived atoms are structured, which async pattern to use, how atom families are keyed, when to use Provider, and where side effects go.

# Jotai project rules

## Stack
- Jotai: 2.x (v2 API only; v1 patterns like useAtomValue from "jotai/react/utils" are deprecated)
- TypeScript: 5.6.x with strict mode and noUncheckedIndexedAccess
- React: 18.3.x with concurrent rendering
- Framework: Next.js 14.x App Router with Server Components
- Utilities: jotai/utils (atomWithStorage, atomWithReset, splitAtom, loadable)
- Effects: jotai-effect (atomEffect) for side effects only, not derived values
- Integration: jotai-tanstack-query for server state; do not hand-roll async fetch atoms

## Project structure
- src/atoms/: one file per domain (user.ts, cart.ts, ui.ts, preferences.ts)
- src/atoms/index.ts: re-exports public atoms and hooks
- src/atoms/{name}/: subdirectory if atom domain exceeds 60 lines
- src/atoms/{name}/selectors.ts: derived atoms that combine primitive atoms
- Components import named atoms from the atoms directory, never define atoms inline

## Atom rules
- Primitive atoms are typed explicitly: atom<UserProfile | null>(null)
- Atom names are noun-based for state, verb-based for write-only: cartItemsAtom, addItemAtom
- One atom per piece of state; never put two independent concerns in one atom
- Atom file exports the atom, not a hook wrapping the atom
- Components call useAtom / useAtomValue / useSetAtom directly

## Derived atom rules
- Derived (read-only) atoms use atom(get => ...) with a synchronous get
- Async derived atoms use atom(async get => { const v = await get(asyncAtom); ... })
- NEVER call get(asyncAtom) synchronously in a sync read function; use loadable() wrapper or async read
- Derived atoms do not have local state; if it needs useState it is a hook, not an atom
- Derived atoms go in {domain}/selectors.ts, not inlined in components

## Async atom rules
- Data fetching uses jotai-tanstack-query: atomWithQuery, atomWithMutation, atomWithInfiniteQuery
- Hand-rolled async atoms are only for data that does not fit the query model (WebSocket, SSE)
- Async atoms that hand-roll fetches suspend by returning a Promise from the read function
- All async atoms are wrapped in Suspense at the first component that reads them
- ErrorBoundary wraps every Suspense that contains an async atom

## Atom family rules
- atomFamily takes a primitive parameter (string, number, boolean) or a serialised key
- NEVER pass an object literal as the atom family parameter; serialise to a string first
- For object parameters, use JSON.stringify or a stable hash before passing to atomFamily
- Atom families used in lists have their atoms removed with atomFamily.remove() in cleanup

## Provider rules
- The global default store is used for app-wide state; no Provider needed at root
- Explicit Provider is required for: test isolation, multi-tenant components, Storybook stories
- Provider instances receive a store created with createStore(), never the default store
- Server Components do not read atoms; atoms are Client Component territory only

## Side effect rules
- Side effects (logging, analytics, localStorage sync not covered by atomWithStorage) use atomEffect
- atomEffect is not a substitute for derived atoms; if the output is a value, use atom(get => ...)
- atomEffect cleans up by returning a cleanup function from the effect
- jotai-effect is a dev dependency only; if not installed, use useEffect in the component instead

## Persistence rules
- Preferences and UI state persist via atomWithStorage with a unique string key
- atomWithStorage defaults to localStorage; for SSR, pass a custom storage with no-op getItem on server
- Session-scoped state (loading, error, transient UI) is never passed to atomWithStorage
- Key naming: "jotai:{domain}:{atomName}" e.g. "jotai:preferences:theme"

The most important rule in this template is the async atom rule. In Jotai v2, the get function inside an async read function returns promises, not resolved values, unless you await them. This is a deliberate design choice: Jotai v2 makes async composable by default. Claude's training data contains a lot of v1 examples where get was synchronous in all contexts. Encoding the rule explicitly prevents Claude from generating async atoms that silently return Promises.

The atom family key rule prevents a class of memory leak that is impossible to detect from reading the code. atomFamily(param) uses Object.is for equality by default, meaning two calls with { userId: "1" } produce two different atoms because the object references differ. In a component that renders on every parent update, this creates a new atom on every render, accumulating atoms in the family's WeakMap until garbage collection clears them. The fix is to pass a primitive: atomFamily(userId) where userId is a string.

For broader CLAUDE.md structure across your project, the CLAUDE.md explained guide covers how to organise rules so Claude Code reads them correctly.

Primitive and derived atoms

Once CLAUDE.md is in place, give Claude a reference atom file to pattern-match against. A concrete example anchors Claude's output to your project's conventions more reliably than rules alone.

// src/atoms/cart.ts
import { atom } from 'jotai'

export type CartItem = {
  id: string
  productId: string
  name: string
  quantity: number
  priceInCents: number
}

// Primitive atoms: one concern each, explicit generic
export const cartItemsAtom = atom<CartItem[]>([])
export const discountCodeAtom = atom<string | null>(null)
export const cartOpenAtom = atom<boolean>(false)

// Write-only action atoms: keep mutation logic in the atom, not in components
export const addItemAtom = atom(
  null,
  (get, set, item: Omit<CartItem, 'id'>) => {
    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, id: crypto.randomUUID() }])
    }
  },
)

export const removeItemAtom = atom(
  null,
  (get, set, id: string) => {
    set(cartItemsAtom, get(cartItemsAtom).filter((i) => i.id !== id))
  },
)

export const clearCartAtom = atom(null, (_get, set) => {
  set(cartItemsAtom, [])
  set(discountCodeAtom, null)
})
// src/atoms/cart/selectors.ts
import { atom } from 'jotai'
import { cartItemsAtom, discountCodeAtom } from '../cart'

// Derived (read-only) atoms live in selectors.ts
export const cartItemCountAtom = atom(
  (get) => get(cartItemsAtom).reduce((sum, i) => sum + i.quantity, 0),
)

export const cartSubtotalAtom = atom(
  (get) =>
    get(cartItemsAtom).reduce(
      (sum, i) => sum + i.priceInCents * i.quantity,
      0,
    ),
)

export const cartSummaryAtom = atom((get) => ({
  itemCount: get(cartItemsAtom).reduce((sum, i) => sum + i.quantity, 0),
  subtotal: get(cartItemsAtom).reduce(
    (sum, i) => sum + i.priceInCents * i.quantity,
    0,
  ),
  discountCode: get(discountCodeAtom),
}))

Three structural decisions matter here.

Write-only action atoms use atom(null, (get, set, arg) => ...). The first argument is the read value, which is null for write-only atoms. Components call const [, addItem] = useAtom(addItemAtom) and invoke addItem(newItem). Keeping mutation logic inside the atom rather than in component code means every component that adds items follows the same deduplication logic, and Claude can extend the logic by editing one file instead of hunting through every component that calls the action.

Derived atoms in selectors.ts are purely computed from other atoms. They have no local state, no side effects, and no async behaviour. Components that need the cart item count import cartItemCountAtom and call useAtomValue(cartItemCountAtom). The component re-renders only when the item count changes, not when other cart fields change. This is the fine-grained reactivity that makes Jotai worth using over a single store.

The explicit generic on primitive atoms is important for TypeScript. atom<CartItem[]>([]) tells the TypeScript compiler that this atom holds CartItem[], even though the initial value is an empty array. Without the generic, the inferred type is never[], which breaks all subsequent reads and writes at the type level. Claude generates the explicit generic when the pattern is in the codebase and omits it about half the time when generating from scratch.

For the Tanstack Query patterns that pair with Jotai for server state, the Claude Code with Tanstack Query guide covers query key design, mutation invalidation, and cache semantics.

Async atoms and jotai-tanstack-query

Async atoms are where Jotai v2 diverges most sharply from both v1 and from store-based managers. Understanding the model prevents the most common Claude failures.

In Jotai v2, a derived atom that reads an async atom receives a Promise if the async atom has not yet resolved. This is intentional. It means you can compose async atoms with the same atom(get => ...) syntax, but you must await each async dependency to get the resolved value.

// src/atoms/user.ts
import { atom } from 'jotai'

// Async primitive: fetches user on first read
export const currentUserAtom = atom(async () => {
  const res = await fetch('/api/me')
  if (!res.ok) throw new Error('Failed to fetch user')
  return res.json() as Promise<UserProfile>
})

// Correct: async derived atom awaits its async dependency
export const userDisplayNameAtom = atom(async (get) => {
  const user = await get(currentUserAtom) // await the async atom
  return user.firstName + ' ' + user.lastName
})

// Wrong: sync derived atom calling get on an async atom
// This returns a Promise<UserProfile>, not a UserProfile
// Claude generates this without explicit rules
export const BROKEN_displayNameAtom = atom((get) => {
  const user = get(currentUserAtom) // user is Promise<UserProfile> here
  return user.firstName + ' ' + user.lastName // TypeError at runtime
})

For real data fetching, jotai-tanstack-query is the recommended integration. It wraps Tanstack Query's caching, deduplication, and background refetching in an atom interface:

// src/atoms/posts.ts
import { atomWithQuery, atomWithMutation } from 'jotai-tanstack-query'
import { queryClient } from '@/lib/query-client'

export const currentUserIdAtom = atom<string | null>(null)

// atomWithQuery takes the same options as useQuery
export const postsAtom = atomWithQuery((get) => ({
  queryKey: ['posts', 'list', get(currentUserIdAtom)],
  queryFn: async ({ queryKey }) => {
    const [, , userId] = queryKey
    const res = await fetch(`/api/posts?userId=${userId}`)
    return res.json()
  },
  enabled: get(currentUserIdAtom) !== null,
  staleTime: 60_000,
}))

// Mutations follow the same interface as useMutation
export const createPostAtom = atomWithMutation(() => ({
  mutationFn: async (data: CreatePostInput) => {
    const res = await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(data),
    })
    return res.json()
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] })
  },
}))

The postsAtom atom re-fetches automatically when currentUserIdAtom changes, because atomWithQuery evaluates its options factory with get on every atom dependency change. This is the Jotai equivalent of putting a value from a Zustand store into a useQuery dependency array, but composable at the atom level rather than inside a component.

Components reading async atoms need a Suspense boundary. The component suspends while the atom is resolving:

// src/components/posts-list.tsx
import { Suspense } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import { postsAtom, createPostAtom } from '@/atoms/posts'
import { ErrorBoundary } from 'react-error-boundary'

function PostsList() {
  // This suspends until postsAtom resolves
  const posts = useAtomValue(postsAtom)
  const createPost = useSetAtom(createPostAtom)

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

export function PostsSection() {
  return (
    <ErrorBoundary fallback={<div>Failed to load posts</div>}>
      <Suspense fallback={<div>Loading posts...</div>}>
        <PostsList />
      </Suspense>
    </ErrorBoundary>
  )
}

For projects where you want non-suspending async atoms, the loadable utility from jotai/utils wraps an async atom in a discriminated union with states loading, hasData, and hasError. The component reads the union and handles each state explicitly without suspending.

Atom families and persistence

Atom families handle parameterised atoms: a user profile atom per user ID, a todo atom per todo ID, or a form field atom per field name. The pattern is clean and powerful when the parameters are primitives. It breaks when the parameters are objects.

// src/atoms/todo.ts
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'

export type Todo = {
  id: string
  text: string
  done: boolean
}

// Correct: string parameter = stable key
export const todoAtomFamily = atomFamily((id: string) =>
  atom<Todo | null>(null),
)

// Wrong: Claude's default when the parameter is naturally object-shaped
// Every render with a new object reference creates a new atom
export const BROKEN_todoAtomFamily = atomFamily((filter: { done: boolean; userId: string }) =>
  atom<Todo[]>([]),
)

// Correct: serialise the object parameter to a stable string key
export const filteredTodosAtomFamily = atomFamily((filter: { done: boolean; userId: string }) =>
  atom<Todo[]>([]),
  (a, b) => a.done === b.done && a.userId === b.userId, // custom equality
)

// Or serialise before calling the family
export const todosByFilterAtomFamily = atomFamily((filterKey: string) =>
  atom<Todo[]>([]),
)
// Usage: todosByFilterAtomFamily(JSON.stringify({ done: true, userId: "1" }))

When atoms from a family are no longer needed, remove them to prevent memory accumulation:

// Inside a component that mounts/unmounts per list item
useEffect(() => {
  return () => {
    todoAtomFamily.remove(todoId)
  }
}, [todoId])

For persisted state, atomWithStorage from jotai/utils reads and writes to localStorage automatically:

// src/atoms/preferences.ts
import { atomWithStorage } from 'jotai/utils'

// atomWithStorage(key, initialValue, storage, options)
export const themeAtom = atomWithStorage<'light' | 'dark' | 'system'>(
  'jotai:preferences:theme',
  'system',
)

export const sidebarOpenAtom = atomWithStorage<boolean>(
  'jotai:ui:sidebarOpen',
  true,
)

atomWithStorage handles the read-from-storage-on-init and write-on-change cycle automatically. The component experience is identical to a plain atom: call useAtom(themeAtom) and get a read value and a setter. The persistence layer is invisible.

For Next.js App Router, atomWithStorage needs an SSR-safe storage because localStorage is not available on the server. Pass a no-op storage for server rendering:

// src/atoms/lib/ssr-storage.ts
import type { SyncStorage } from 'jotai/vanilla/utils'

export function createSSRStorage<T>(): SyncStorage<T> {
  if (typeof window === 'undefined') {
    return {
      getItem: (_key, initialValue) => initialValue,
      setItem: () => undefined,
      removeItem: () => undefined,
    }
  }
  return {
    getItem: (key, initialValue) => {
      try {
        const raw = window.localStorage.getItem(key)
        return raw !== null ? (JSON.parse(raw) as T) : initialValue
      } catch {
        return initialValue
      }
    },
    setItem: (key, value) => {
      try {
        window.localStorage.setItem(key, JSON.stringify(value))
      } catch {
        // quota exceeded; ignore
      }
    },
    removeItem: (key) => {
      window.localStorage.removeItem(key)
    },
  }
}

// Usage
export const themeAtom = atomWithStorage<'light' | 'dark' | 'system'>(
  'jotai:preferences:theme',
  'system',
  createSSRStorage<'light' | 'dark' | 'system'>(),
)

The SSR storage returns the initial value on the server so the HTML matches the client's initial render, then switches to real localStorage once the component hydrates on the client.

Side effects with jotai-effect and Provider scope

Jotai's composition model handles derived values and async data cleanly. Side effects, things that happen in response to atom changes but produce no value, need a different tool.

atomEffect from the jotai-effect package runs a function when its atom dependencies change, similar to a useEffect that watches atoms rather than React state:

// src/atoms/analytics.ts
import { atomEffect } from 'jotai-effect'
import { cartItemsAtom } from './cart'
import { currentUserIdAtom } from './user'

// Fires analytics event when cart changes
export const cartAnalyticsEffect = atomEffect((get) => {
  const items = get(cartItemsAtom)
  const userId = get(currentUserIdAtom)

  if (items.length === 0) return

  // Side effect: analytics, not a derived value
  analytics.track('cart_updated', {
    userId,
    itemCount: items.reduce((n, i) => n + i.quantity, 0),
    subtotal: items.reduce((n, i) => n + i.priceInCents * i.quantity, 0),
  })

  // Cleanup if needed
  return () => {
    // cancel subscriptions, abort requests, etc.
  }
})

To activate an atomEffect, a component mounts it with useAtomValue:

// src/components/analytics-mount.tsx
'use client'

import { useAtomValue } from 'jotai'
import { cartAnalyticsEffect } from '@/atoms/analytics'

// Mount this once at the app level to activate the effect
export function AnalyticsMount() {
  useAtomValue(cartAnalyticsEffect)
  return null
}

The distinction between derived atoms and atomEffect is worth encoding explicitly in CLAUDE.md. If the result of watching an atom is a value, use a derived atom. If the result is a side effect with no return value, use atomEffect. Claude defaults to atomEffect for both, likely because it reads like a familiar useEffect. The rule prevents a pattern where your atom graph is littered with effects that should be derivations.

Provider scope is the other area where explicit rules pay off. By default, Jotai uses a global store. Every atom is shared across the entire app. This is correct for app-wide state like theme, user profile, and cart. It is incorrect for state that should be isolated per component tree, like a wizard form, a modal with its own state, or a test environment.

// src/components/wizard/provider.tsx
'use client'

import { createStore, Provider } from 'jotai'
import { type ReactNode } from 'react'
import { wizardStepAtom, wizardDataAtom } from '@/atoms/wizard'

// Each Wizard instance gets its own isolated store
export function WizardProvider({ children }: { children: ReactNode }) {
  const store = createStore()
  // Seed the store with initial values if needed
  store.set(wizardStepAtom, 0)
  store.set(wizardDataAtom, {})

  return <Provider store={store}>{children}</Provider>
}

Wrapping a component tree in <Provider store={store}> isolates all atom reads and writes to that store instance. Two wizards rendered simultaneously get separate state. Tests can create a store, seed it, and assert on it without touching the global store. Storybook stories are isolated by default.

For broader Next.js App Router patterns that interact with client-side state, the Claude Code with Next.js guide covers server and client component boundaries that affect where atoms can be read.

Hard rules summary

The patterns above reduce to a concrete list that belongs at the top of every Jotai CLAUDE.md.

  1. Atoms are typed explicitly: atom<Type>(initialValue), never rely on inference from the initial value.
  2. Atom names follow the {noun}Atom convention for state and {verb}Atom for write-only actions.
  3. Primitive atoms hold one concern each; never bundle two independent state fields into one atom.
  4. Derived atoms use atom(get => ...) for synchronous values and atom(async get => ...) for async; never call get synchronously on an async atom.
  5. All async atoms are wrapped in Suspense at the first component that reads them, with an ErrorBoundary around each Suspense.
  6. jotai-tanstack-query is used for server state; hand-rolled fetch atoms are for WebSocket and SSE only.
  7. Atom families take primitive parameters (string, number) or a custom equality comparator; never pass raw object literals as parameters.
  8. Atom family entries are removed in cleanup when the component unmounts: atomFamily.remove(param).
  9. Persistence uses atomWithStorage with a unique jotai:{domain}:{name} key and an SSR-safe storage adapter for Next.js.
  10. Transient state (loading flags, error messages, pending request IDs) is never passed to atomWithStorage.
  11. Side effects use atomEffect from jotai-effect; derived values use atom(get => ...). The two are not interchangeable.
  12. The global default store is used for app-wide atoms; explicit Provider with createStore() is used for test isolation, multi-tenant rendering, and Storybook.
  13. Atoms are defined in src/atoms/{domain}.ts files; no inline atom definitions inside components.
  14. Components use useAtomValue for read-only access and useSetAtom for write-only access; useAtom is reserved for components that genuinely need both.

These rules prevent the five most common Claude Jotai failures: async atom misuse, atom family memory leaks, missing Suspense boundaries, atomEffect overuse, and Provider misconfiguration. With them in CLAUDE.md, Claude Code generates idiomatic Jotai 2+ code on the first pass rather than producing v1 patterns or silent runtime errors that only surface in production.

The atomic model rewards consistency. Atoms that are small, well-named, and correctly typed compose into complex derived state without friction. The CLAUDE.md template above gives Claude the structural context to generate atoms that stay small and composable as the codebase grows. Claudify includes a Jotai-specific CLAUDE.md template pre-configured for Jotai 2+, jotai-effect, jotai-tanstack-query, and SSR-safe persistence. For the broader role of CLAUDE.md across your stack, the Claude Code best practices guide covers how to structure rules so Claude Code applies them consistently.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir