Atomic State with Jotai
Top-Down vs Bottom-Up
Zustand is top-down: you define a store with all your state, then components reach in with selectors to grab what they need. It's like a big warehouse — you organize everything centrally and distribute from there.
Jotai flips this completely. It's bottom-up: you create tiny, independent atoms of state, and compose them into larger structures as needed. It's like LEGO — each piece is self-contained, and you build whatever you need by snapping them together.
Neither is universally better. They shine in different scenarios, and understanding when to reach for each is what separates thoughtful architecture from cargo culting.
Jotai atoms are like reactive spreadsheet cells. Each cell (atom) holds a value. Some cells reference other cells (derived atoms), and when a source cell changes, all dependent cells recompute automatically. Components subscribe to individual cells, not the entire spreadsheet. If cell A3 changes and your component only reads B7, you don't re-render. The dependency graph is implicit — you declare relationships, and Jotai tracks the reactive chain.
Primitive Atoms
The simplest building block. A primitive atom holds a single value:
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
const nameAtom = atom('');
const darkModeAtom = atom(false);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
Atoms are created outside components — they're stable references. Any component that calls useAtom(countAtom) shares the same piece of state. No Provider needed (though Jotai does use one internally via a default store).
Derived Atoms
This is where Jotai starts to feel like a superpower. Derived atoms compute values from other atoms — and they automatically track dependencies:
const itemsAtom = atom<Item[]>([]);
const filterAtom = atom('');
const filteredItemsAtom = atom((get) => {
const items = get(itemsAtom);
const filter = get(filterAtom);
if (!filter) return items;
return items.filter((item) =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
});
filteredItemsAtom is read-only — it has no setter. It recomputes automatically when itemsAtom or filterAtom changes. Components that subscribe to filteredItemsAtom only re-render when the filtered result actually changes.
Read-Write Derived Atoms
You can also create derived atoms that are writable:
const celsiusAtom = atom(25);
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9 / 5 + 32,
(_get, set, newFahrenheit: number) => {
set(celsiusAtom, (newFahrenheit - 32) * 5 / 9);
}
);
Reading fahrenheitAtom gives you the celsius value converted. Writing to it converts back and updates the celsius atom. One source of truth, two access patterns.
Async Atoms
Atoms can be asynchronous. When an async atom is read, it integrates with React Suspense:
const userIdAtom = atom(1);
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`/api/users/${id}`);
return response.json();
});
function UserProfile() {
const [user] = useAtom(userAtom);
return <div>{user.name}</div>;
}
When userIdAtom changes, userAtom refetches automatically. The component suspends during the fetch (handled by a Suspense boundary). No manual loading states.
Async atoms don't provide caching, deduplication, background refetching, or retry logic. For server state, TanStack Query is still the right tool. Use async atoms for derived computations that happen to be asynchronous (e.g., a computation offloaded to a Web Worker), not as a replacement for data fetching.
Atom Families
When you need dynamic atoms — one per item in a list, one per user, one per tab — use atom families:
import { atomFamily } from 'jotai/utils';
const todoAtomFamily = atomFamily((id: string) =>
atom<Todo>({ id, text: '', completed: false })
);
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id));
return (
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodo((t) => ({ ...t, completed: !t.completed }))}
/>
{todo.text}
</label>
);
}
atomFamily creates or retrieves an atom for each unique parameter. Calling todoAtomFamily('abc') twice returns the same atom instance. Each todo item has its own atom — updating one todo only re-renders that single TodoItem.
Atom family garbage collection
Atom families store atoms in an internal map keyed by parameter. By default, atoms are never removed from this map — even if no component uses them. For long-running apps with many dynamic atoms (chat messages, log entries), this can leak memory.
You can pass a custom areEqual function and manually remove atoms:
const messageAtomFamily = atomFamily(
(id: string) => atom<Message | null>(null),
(a, b) => a === b
);
messageAtomFamily.remove('old-message-id');Jotai vs Zustand
This is the comparison everyone asks about. Here's the honest answer:
| Dimension | Zustand (Top-Down) | Jotai (Bottom-Up) |
|---|---|---|
| Mental model | One store, many selectors | Many atoms, composed as needed |
| State definition | Centralized — all state in one place | Distributed — atoms defined near usage |
| Re-render optimization | Manual selectors (you choose what to subscribe to) | Automatic (each atom is its own subscription) |
| Derived state | Compute in selectors or useMemo | Derived atoms with automatic dependency tracking |
| Code organization | Store files with slices | Atom files co-located with features |
| Middleware | Rich: persist, devtools, immer | Utilities: atomWithStorage, atomWithReset |
| Best for | Moderate shared state with clear structure | Many independent pieces, complex derivations |
| Learning curve | Very low — just create + selector | Low but conceptual shift for derived atoms |
| DevTools | Redux DevTools | Jotai DevTools (separate package) |
| Bundle size | ~1.1 kB | ~2.4 kB (core) |
When Jotai Shines
Jotai excels when you have many independent pieces of state that compose into derived values. Think:
- Spreadsheet-like UIs — each cell is an atom, formulas are derived atoms
- Graph editors — each node is an atom, connections are derived atoms
- Configuration panels — many independent toggles/sliders that combine into a computed config
- Collaborative tools — per-user atoms composed into shared views
When Zustand Wins
Zustand is better when you have a clear, structured store with well-defined actions. Think:
- Application UI state — sidebar, modals, toasts with coordinated behavior
- Feature stores — a shopping cart with items, totals, and checkout flow
- State that needs persistence — Zustand's persist middleware is more mature
- Teams familiar with Redux patterns — the mental model transfers directly
Practical Patterns
Atom with Storage
Jotai provides built-in persistence via atomWithStorage:
import { atomWithStorage } from 'jotai/utils';
const themeAtom = atomWithStorage<'light' | 'dark' | 'system'>('theme', 'system');
const fontSizeAtom = atomWithStorage('fontSize', 16);
It uses localStorage by default, syncs across browser tabs, and handles SSR hydration.
Reset Atoms
import { atomWithReset, useResetAtom } from 'jotai/utils';
const filtersAtom = atomWithReset({
category: 'all',
priceRange: [0, 1000] as [number, number],
sortBy: 'relevance',
});
function FilterPanel() {
const [filters, setFilters] = useAtom(filtersAtom);
const resetFilters = useResetAtom(filtersAtom);
return (
<div>
{/* filter controls */}
<button onClick={resetFilters}>Reset Filters</button>
</div>
);
}
Combining Atoms for Complex State
const searchQueryAtom = atom('');
const selectedCategoryAtom = atom<string | null>(null);
const sortOrderAtom = atom<'asc' | 'desc'>('asc');
const searchConfigAtom = atom((get) => ({
query: get(searchQueryAtom),
category: get(selectedCategoryAtom),
sortOrder: get(sortOrderAtom),
}));
Each piece of search state is independent — the search input doesn't re-render the category selector. But searchConfigAtom composes them into a single config object for the API call.
| What developers do | What they should do |
|---|---|
| Creating atoms inside components — new atom on every render An atom created inside a component is a new atom instance on every render, losing all shared state. Atoms are stable references — define them once. | Define atoms at module scope (outside components) or use useMemo for truly dynamic atoms |
| Using async atoms as a data fetching solution instead of TanStack Query Async atoms re-fetch on every dependency change with no cache layer. TanStack Query provides an entire cache infrastructure. | Use TanStack Query for server state. Async atoms lack caching, deduplication, refetching, and retry. |
| Creating a single massive derived atom that depends on 20+ source atoms A derived atom recomputes when ANY dependency changes. If it depends on 20 atoms, it recomputes 20 times more often than necessary. Intermediate atoms narrow the dependency graph. | Break complex derivations into intermediate derived atoms |
- 1Atoms are defined at module scope — never inside components. They are stable identity references.
- 2Derived atoms automatically track dependencies. No manual dependency arrays like useEffect.
- 3Atom families create or retrieve atoms per key — perfect for per-item state in lists.
- 4Async atoms integrate with Suspense but are NOT a replacement for data fetching libraries.
- 5Choose Jotai when you have many independent state pieces with complex derivations. Choose Zustand for structured stores with clear actions.