Jotai Atomic State Management
Jotai atomic state for React — primitive/derived/async atoms, Provider-less mode, atomWithStorage, atomWithQuery, and React Suspense integration
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 linesJotai 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
- Keep atoms small and focused — one atom per meaningful piece of state; compose with derived atoms.
- Use
useAtomValue/useSetAtomwhen a component only reads or only writes, to minimize subscriptions. - Embrace Suspense for async — async atoms integrate cleanly with
<Suspense>boundaries instead of manual loading states. - Prefer derived atoms over effects — compute values reactively rather than syncing state in
useEffect. - Use atom families for entity collections — avoids re-rendering the entire list when one item changes.
- Scope Providers for isolation — use
<Provider>to create independent state trees for testing or parallel UI sections. - Colocate related atoms — group atoms by feature domain in a single file for discoverability.
Anti-Patterns
- Storing everything in one atom — defeats the atomic model; components subscribe to more state than they need.
- Circular atom dependencies — atom A derives from B which derives from A causes infinite loops.
- Using
useEffectto sync atoms — creates extra render cycles; prefer derived atoms that compute inline. - Ignoring Suspense boundaries — async atoms without a
<Suspense>wrapper cause unhandled promise errors. - Creating atoms inside components — atoms must be stable references; creating them in render creates a new atom every render.
- 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
Related Skills
Legend State
Legend-State high-performance observable state for React — fine-grained reactivity, persistence plugins, computed observables, and sync engine
Nanostores
Nanostores tiny framework-agnostic state manager — atoms, computed stores, lifecycle events, and integrations with React, Vue, Svelte, and vanilla JS
Redux Toolkit
Redux Toolkit for scalable React state — createSlice, configureStore, RTK Query, createAsyncThunk, entity adapter, middleware, and TypeScript patterns
TanStack Query (React Query)
TanStack Query for server state — useQuery, useMutation, query invalidation, optimistic updates, infinite queries, prefetching, and SSR hydration
Valtio Proxy State Management
Valtio proxy-based state for React — mutable-style API with automatic tracking, snapshots, derived state, and nested object support
XState State Machines
XState for state machines and statecharts in React — actors, guards, actions, services, @xstate/react integration, and the visual editor