Skip to main content
Technology & EngineeringState Management374 lines

Jotai Atomic State Management

Jotai atomic state for React — primitive/derived/async atoms, Provider-less mode, atomWithStorage, atomWithQuery, and React Suspense integration

Quick Summary33 lines
Jotai takes a bottom-up approach to React state management inspired by Recoil. State is composed of atoms — small, independent units of state that can be combined, derived, and consumed individually. Unlike top-down stores, atoms avoid the "extra re-render" problem by design: components subscribe only to the atoms they read. Jotai embraces React's concurrent features, integrating naturally with Suspense for async data.

## Key Points

- **Atomic model** — state is built from the smallest meaningful pieces upward
- **No boilerplate** — atoms are plain values; no reducers, action types, or selectors needed
- **Provider-less by default** — a default store lives at the module level
- **Suspense-native** — async atoms suspend automatically, no loading booleans required
- **Composable** — derived atoms combine other atoms like spreadsheet formulas
- **TypeScript-inferred** — atom types flow through without manual annotation
1. **Keep atoms small and focused** — one atom per meaningful piece of state; compose with derived atoms.
2. **Use `useAtomValue` / `useSetAtom`** when a component only reads or only writes, to minimize subscriptions.
3. **Embrace Suspense for async** — async atoms integrate cleanly with `<Suspense>` boundaries instead of manual loading states.
4. **Prefer derived atoms over effects** — compute values reactively rather than syncing state in `useEffect`.
5. **Use atom families for entity collections** — avoids re-rendering the entire list when one item changes.
6. **Scope Providers for isolation** — use `<Provider>` to create independent state trees for testing or parallel UI sections.

## Quick Example

```bash
npm install jotai
# Optional utilities
npm install jotai/utils   # bundled with jotai
```

```typescript
// atoms/counterAtom.ts
import { atom } from 'jotai';

export const countAtom = atom(0);
```
skilldb get state-management-skills/Jotai Atomic State ManagementFull skill: 374 lines
Paste into your CLAUDE.md or agent config

Jotai Atomic State Management

Core Philosophy

Jotai takes a bottom-up approach to React state management inspired by Recoil. State is composed of atoms — small, independent units of state that can be combined, derived, and consumed individually. Unlike top-down stores, atoms avoid the "extra re-render" problem by design: components subscribe only to the atoms they read. Jotai embraces React's concurrent features, integrating naturally with Suspense for async data.

  • Atomic model — state is built from the smallest meaningful pieces upward
  • No boilerplate — atoms are plain values; no reducers, action types, or selectors needed
  • Provider-less by default — a default store lives at the module level
  • Suspense-native — async atoms suspend automatically, no loading booleans required
  • Composable — derived atoms combine other atoms like spreadsheet formulas
  • TypeScript-inferred — atom types flow through without manual annotation

Setup

npm install jotai
# Optional utilities
npm install jotai/utils   # bundled with jotai
// atoms/counterAtom.ts
import { atom } from 'jotai';

export const countAtom = atom(0);
// components/Counter.tsx
import { useAtom } from 'jotai';
import { countAtom } from '../atoms/counterAtom';

export function Counter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
}

Key Techniques

Primitive Atoms

import { atom } from 'jotai';

// Simple value atoms
export const nameAtom = atom('');
export const ageAtom = atom(0);
export const isDarkModeAtom = atom(false);
export const selectedIdsAtom = atom<Set<string>>(new Set());

// Read-only atom
export const appVersionAtom = atom(() => '2.1.0');

Derived Atoms (Read-Only)

import { atom } from 'jotai';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export const cartItemsAtom = atom<CartItem[]>([]);

// Derived: compute total from cart items
export const cartTotalAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});

// Derived: count of items
export const cartCountAtom = atom((get) => {
  return get(cartItemsAtom).reduce((sum, item) => sum + item.quantity, 0);
});

// Derived from multiple atoms
export const userDisplayAtom = atom((get) => {
  const name = get(nameAtom);
  const age = get(ageAtom);
  return `${name} (${age})`;
});

Read-Write Derived Atoms

// A derived atom with custom write logic
export const temperatureCelsiusAtom = atom(25);

export const temperatureFahrenheitAtom = atom(
  (get) => get(temperatureCelsiusAtom) * 9 / 5 + 32,
  (get, set, newFahrenheit: number) => {
    set(temperatureCelsiusAtom, (newFahrenheit - 32) * 5 / 9);
  }
);

// Write-only atom (action atom)
export const addToCartAtom = atom(
  null, // no read value
  (get, set, newItem: CartItem) => {
    const items = get(cartItemsAtom);
    const existing = items.find((i) => i.id === newItem.id);
    if (existing) {
      set(
        cartItemsAtom,
        items.map((i) =>
          i.id === newItem.id
            ? { ...i, quantity: i.quantity + newItem.quantity }
            : i
        )
      );
    } else {
      set(cartItemsAtom, [...items, newItem]);
    }
  }
);

Async Atoms with Suspense

import { atom } from 'jotai';

interface User {
  id: string;
  name: string;
  email: string;
}

export const userIdAtom = atom<string | null>(null);

// Async derived atom — suspends while loading
export const userAtom = atom(async (get) => {
  const id = get(userIdAtom);
  if (!id) return null;

  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to fetch user');
  return res.json() as Promise<User>;
});

// Async read-write atom
export const userProfileAtom = atom(
  async (get) => {
    const res = await fetch('/api/profile');
    return res.json() as Promise<User>;
  },
  async (get, set, update: Partial<User>) => {
    const current = await get(userProfileAtom);
    const res = await fetch('/api/profile', {
      method: 'PATCH',
      body: JSON.stringify(update),
      headers: { 'Content-Type': 'application/json' },
    });
    const updated = await res.json();
    set(userProfileAtom, updated);
  }
);
// components/UserProfile.tsx
import { Suspense } from 'react';
import { useAtomValue } from 'jotai';
import { userAtom } from '../atoms/userAtoms';

function UserDetails() {
  const user = useAtomValue(userAtom);
  if (!user) return <p>Select a user</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

export function UserProfile() {
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserDetails />
    </Suspense>
  );
}

atomWithStorage

import { atomWithStorage } from 'jotai/utils';

// Automatically persists to localStorage
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
export const localeAtom = atomWithStorage('locale', 'en');

// Custom storage (sessionStorage)
export const sessionTokenAtom = atomWithStorage(
  'session-token',
  '',
  sessionStorage
);
function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  );
}

atomWithQuery (via jotai-tanstack-query)

npm install jotai-tanstack-query @tanstack/react-query
import { atomWithQuery, atomWithMutation } from 'jotai-tanstack-query';

interface Post {
  id: number;
  title: string;
  body: string;
}

export const postsAtom = atomWithQuery(() => ({
  queryKey: ['posts'],
  queryFn: async (): Promise<Post[]> => {
    const res = await fetch('/api/posts');
    return res.json();
  },
}));

// Dependent query — uses another atom's value
export const selectedPostIdAtom = atom<number | null>(null);

export const postDetailAtom = atomWithQuery((get) => {
  const id = get(selectedPostIdAtom);
  return {
    queryKey: ['post', id],
    queryFn: async (): Promise<Post> => {
      const res = await fetch(`/api/posts/${id}`);
      return res.json();
    },
    enabled: id !== null,
  };
});

// Mutation atom
export const createPostAtom = atomWithMutation(() => ({
  mutationFn: async (newPost: Omit<Post, 'id'>) => {
    const res = await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(newPost),
      headers: { 'Content-Type': 'application/json' },
    });
    return res.json();
  },
}));

Provider-less Mode vs Scoped Providers

// Provider-less: atoms share a default store (module-level singleton)
function App() {
  return <Counter />;
}

// Scoped provider: isolate atom state per subtree
import { Provider } from 'jotai';

function App() {
  return (
    <>
      <Provider>
        <Counter /> {/* has its own countAtom instance */}
      </Provider>
      <Provider>
        <Counter /> {/* independent countAtom instance */}
      </Provider>
    </>
  );
}

Atom Families

import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';

// Create an atom per entity ID
export const todoAtomFamily = atomFamily((id: string) =>
  atom<Todo>({ id, title: '', completed: false })
);

// Usage: each todo gets its own atom
function TodoItem({ id }: { id: string }) {
  const [todo, setTodo] = useAtom(todoAtomFamily(id));

  return (
    <label>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => setTodo({ ...todo, completed: !todo.completed })}
      />
      {todo.title}
    </label>
  );
}

useAtomValue and useSetAtom

import { useAtomValue, useSetAtom } from 'jotai';

// Read-only — component never re-renders from writes
function CartBadge() {
  const count = useAtomValue(cartCountAtom);
  return <span className="badge">{count}</span>;
}

// Write-only — component never re-renders from reads
function AddButton({ item }: { item: CartItem }) {
  const addToCart = useSetAtom(addToCartAtom);
  return <button onClick={() => addToCart(item)}>Add</button>;
}

Best Practices

  1. Keep atoms small and focused — one atom per meaningful piece of state; compose with derived atoms.
  2. Use useAtomValue / useSetAtom when a component only reads or only writes, to minimize subscriptions.
  3. Embrace Suspense for async — async atoms integrate cleanly with <Suspense> boundaries instead of manual loading states.
  4. Prefer derived atoms over effects — compute values reactively rather than syncing state in useEffect.
  5. Use atom families for entity collections — avoids re-rendering the entire list when one item changes.
  6. Scope Providers for isolation — use <Provider> to create independent state trees for testing or parallel UI sections.
  7. Colocate related atoms — group atoms by feature domain in a single file for discoverability.

Anti-Patterns

  1. Storing everything in one atom — defeats the atomic model; components subscribe to more state than they need.
  2. Circular atom dependencies — atom A derives from B which derives from A causes infinite loops.
  3. Using useEffect to sync atoms — creates extra render cycles; prefer derived atoms that compute inline.
  4. Ignoring Suspense boundaries — async atoms without a <Suspense> wrapper cause unhandled promise errors.
  5. Creating atoms inside components — atoms must be stable references; creating them in render creates a new atom every render.
  6. Overusing atom families without cleanup — atom families cache atoms forever by default; call atomFamily.remove(key) when entities are deleted.

Install this skill directly: skilldb add state-management-skills

Get CLI access →