Atom-Based State (Recoil & Jotai)
Fine-grained state management with atoms for optimal performance
Atom-based state management breaks state into small, independent units (atoms) that components can subscribe to individually. This eliminates unnecessary re-renders and reduces boilerplate.
The Atom Concept
Traditional Context:
UserContext changes → All consumers re-render
Atoms:
nameAtom changes → Only name subscribers re-render
emailAtom changes → Only email subscribers re-renderJotai (Simpler, Recommended)
Basic Setup
// No provider needed in most cases!
import { atom } from 'jotai';
// Define atoms
export const countAtom = atom(0);
export const nameAtom = atom('John');
export const emailAtom = atom('john@example.com');Using Atoms
// components/Counter.tsx
import { useAtom } from 'jotai';
import { countAtom } from '@/atoms';
export function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
// Read-only
export function CountDisplay() {
const [count] = useAtom(countAtom);
return <div>{count}</div>;
}
// Write-only (no re-render on value change!)
export function CountButtons() {
const [, setCount] = useAtom(countAtom);
return (
<>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(c => c - 1)}>-</button>
</>
);
}Derived Atoms
// atoms/user.ts
import { atom } from 'jotai';
export const firstNameAtom = atom('John');
export const lastNameAtom = atom('Doe');
// Derived atom (computed from other atoms)
export const fullNameAtom = atom(
(get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`
);
// Writable derived atom
export const fullNameWritableAtom = atom(
(get) => `${get(firstNameAtom)} ${get(lastNameAtom)}`,
(get, set, newName: string) => {
const [first, last] = newName.split(' ');
set(firstNameAtom, first);
set(lastNameAtom, last);
}
);Async Atoms
// atoms/user.ts
import { atom } from 'jotai';
interface User {
id: string;
name: string;
email: string;
}
// Async atom
export const userIdAtom = atom('123');
export const userAtom = atom(async (get) => {
const userId = get(userIdAtom);
const response = await fetch(`/api/users/${userId}`);
return response.json() as Promise<User>;
});
// Usage (automatically suspends)
export function UserProfile() {
const [user] = useAtom(userAtom);
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Wrap in Suspense
export function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
);
}Atom Families (Dynamic Atoms)
// atoms/posts.ts
import { atomFamily } from 'jotai/utils';
// Create atom for each post ID
export const postAtomFamily = atomFamily((id: string) =>
atom(async () => {
const response = await fetch(`/api/posts/${id}`);
return response.json();
})
);
// Usage
export function Post({ id }: { id: string }) {
const [post] = useAtom(postAtomFamily(id));
return <div>{post.title}</div>;
}Persistence
// atoms/settings.ts
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Automatically persisted to localStorage
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
// Usage (automatic save/load)
export function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}Recoil (More Features, More Complex)
Setup
// app/layout.tsx
import { RecoilRoot } from 'recoil';
export default function RootLayout({ children }: Props) {
return (
<RecoilRoot>
{children}
</RecoilRoot>
);
}Atoms
// state/atoms.ts
import { atom } from 'recoil';
export const countState = atom({
key: 'countState', // unique ID
default: 0,
});
export const userState = atom({
key: 'userState',
default: {
id: '',
name: '',
email: '',
},
});Selectors (Derived State)
// state/selectors.ts
import { selector } from 'recoil';
import { countState } from './atoms';
export const doubleCountState = selector({
key: 'doubleCountState',
get: ({ get }) => {
const count = get(countState);
return count * 2;
},
});
// Async selector
export const userDataState = selector({
key: 'userDataState',
get: async ({ get }) => {
const userId = get(userIdState);
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
});Using in Components
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { countState, doubleCountState } from '@/state/atoms';
// Read and write
export function Counter() {
const [count, setCount] = useRecoilState(countState);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
// Read only
export function DoubleCount() {
const doubleCount = useRecoilValue(doubleCountState);
return <div>{doubleCount}</div>;
}
// Write only
export function CountControls() {
const setCount = useSetRecoilState(countState);
return (
<button onClick={() => setCount(c => c + 1)}>
Increment
</button>
);
}Atom Effects (Side Effects)
// state/atoms.ts
import { atom } from 'recoil';
export const themeState = atom({
key: 'themeState',
default: 'light',
effects: [
// Sync with localStorage
({ onSet, setSelf }) => {
const stored = localStorage.getItem('theme');
if (stored) setSelf(stored);
onSet((newValue) => {
localStorage.setItem('theme', newValue);
});
},
// Sync with document class
({ onSet }) => {
onSet((newValue) => {
document.documentElement.classList.toggle('dark', newValue === 'dark');
});
},
],
});Comparison: Jotai vs Recoil
// Jotai (simpler)
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);
// Recoil (more verbose)
const countState = atom({
key: 'countState',
default: 0,
});
const [count, setCount] = useRecoilState(countState);Jotai Pros:
- No provider needed
- Less boilerplate
- Smaller bundle size
- TypeScript-first
Recoil Pros:
- More mature
- Better DevTools
- Atom effects
- More documentation
Advanced Patterns
Optimistic Updates
// atoms/posts.ts
import { atom } from 'jotai';
export const postsAtom = atom<Post[]>([]);
export const createPostAtom = atom(
null,
async (get, set, newPost: Omit<Post, 'id'>) => {
// Optimistic update
const tempPost = { ...newPost, id: 'temp' };
set(postsAtom, [...get(postsAtom), tempPost]);
try {
// API call
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
const savedPost = await response.json();
// Replace temp with real
set(postsAtom, posts =>
posts.map(p => p.id === 'temp' ? savedPost : p)
);
} catch (error) {
// Rollback on error
set(postsAtom, posts =>
posts.filter(p => p.id !== 'temp')
);
throw error;
}
}
);Reset Pattern
// atoms/form.ts
import { atom } from 'jotai';
import { RESET } from 'jotai/utils';
export const nameAtom = atom('');
export const emailAtom = atom('');
export const formAtom = atom(
(get) => ({
name: get(nameAtom),
email: get(emailAtom),
}),
(get, set, update: typeof RESET | Partial<{ name: string; email: string }>) => {
if (update === RESET) {
set(nameAtom, '');
set(emailAtom, '');
} else {
if (update.name !== undefined) set(nameAtom, update.name);
if (update.email !== undefined) set(emailAtom, update.email);
}
}
);
// Usage
const [, setForm] = useAtom(formAtom);
setForm(RESET); // Reset all fieldsComputed Arrays
// atoms/todos.ts
import { atom } from 'jotai';
export const todosAtom = atom<Todo[]>([]);
export const filterAtom = atom<'all' | 'active' | 'completed'>('all');
export const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
switch (filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
});
export const todoStatsAtom = atom((get) => {
const todos = get(todosAtom);
return {
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length,
};
});Testing
// components/__tests__/Counter.test.tsx
import { renderHook, act } from '@testing-library/react';
import { useAtom } from 'jotai';
import { countAtom } from '@/atoms';
test('countAtom increments', () => {
const { result } = renderHook(() => useAtom(countAtom));
act(() => {
result.current[1](5);
});
expect(result.current[0]).toBe(5);
});DevTools
// Install jotai-devtools
import { useAtomsDebugValue } from 'jotai-devtools';
export function DebugAtoms() {
useAtomsDebugValue();
return null;
}
// Add to app
<DebugAtoms />Best Practices
- Small atoms: One piece of state per atom
- Derive don't duplicate: Use computed atoms
- Co-locate: Define atoms near usage
- Type atoms: Full TypeScript support
- Lazy loading: Only create atoms when needed
- Persistence: Use atomWithStorage for saved state
- Testing: Test atoms independently
- DevTools: Use for debugging
- Async carefully: Handle loading/error states
- Reset pattern: Clear form state easily
When to Use Atoms
✅ Use atoms when:
- Need fine-grained subscriptions
- Performance is critical
- Minimal boilerplate desired
- Lots of derived state
- Dynamic state (atom families)
❌ Don't use atoms when:
- Simple global state (use Context)
- Need time-travel (use Redux)
- Team unfamiliar with concept
- Debugging tools essential
Common Pitfalls
❌ Too many atoms: One per field
✅ Group related state
❌ Not using derived atoms: Duplicate state
✅ Compute from source atoms
❌ Missing Suspense: Async atoms crash
✅ Wrap in Suspense boundary
❌ Forgetting write-only: Unnecessary re-renders
✅ Use [, setAtom] for setters
❌ Complex atom logic: Hard to debug
✅ Keep atoms simple, compose with derived
Atom-based state provides fine-grained reactivity with minimal boilerplate—perfect for performance-critical apps!