React Jotai: A Practical Guide to Atomic State Management
A practical guide to React Jotai atomic state: concepts, patterns, async atoms, performance, TypeScript, SSR, testing, and pitfalls.
Image used for representation purposes only.
Overview
Jotai brings atomic state management to React. Instead of hoisting a single global store or scattering context providers, you compose small, focused atoms—each representing a piece of state or a derived value—and wire them together like pure functions. The result is predictable, type-safe, and performant state flows that scale from a single component to an entire application.
This guide covers the mental model, core APIs, async patterns, performance, TypeScript, SSR, testing, and best practices to help you ship confidently with Jotai.
Why atomic state?
Traditional global stores often couple unrelated parts of the UI and re-render too broadly. Jotai flips that model:
- Each atom is an independent unit of state.
- Components subscribe to only the atoms they use.
- Derived atoms are pure and recompute only when their dependencies change.
- Updates are granular, minimizing unnecessary renders.
The outcome is a system that is easy to reason about, resilient to refactors, and friendly to React’s concurrent rendering.
The Jotai mental model
- Atom: the smallest state unit. Think of it as a React.useState that lives outside a component.
- Read function (get): lets a derived atom read other atoms.
- Write function (set): lets an atom update itself or other atoms.
- Store/Provider: an optional container that scopes a set of atoms (useful for tests, SSR, or multi-instance UIs).
Getting started
Install and wire up the provider once at your app’s root.
npm install jotai
// app.tsx
import { Provider } from 'jotai';
export default function App({ children }: { children: React.ReactNode }) {
return <Provider>{children}</Provider>;
}
Note: You can also use Jotai without an explicit Provider—the default store will be used. Introduce a Provider when you need store isolation (tests, SSR per request, multiple independent app shells).
Primitive and derived atoms
Start with primitive atoms, then layer derivations.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// Primitive atom
const countAtom = atom(0);
// Derived (read-only) atom
const doubledAtom = atom((get) => get(countAtom) * 2);
// Writable derived atom (read + write logic)
const incrementAtom = atom(
(get) => get(countAtom),
(get, set, delta: number = 1) => set(countAtom, get(countAtom) + delta)
);
function Counter() {
const [count] = useAtom(countAtom);
const doubled = useAtomValue(doubledAtom);
const increment = useSetAtom(incrementAtom);
return (
<div>
<p>Count: {count} | Doubled: {doubled}</p>
<button onClick={() => increment(1)}>+1</button>
</div>
);
}
Key points:
- Components re-render only when atoms they read change.
- Prefer useAtomValue for read-only consumption and useSetAtom for event handlers.
Transactions with useAtomCallback
When a write needs to read and update multiple atoms atomically, use a callback.
import { atom } from 'jotai';
import { useAtomCallback } from 'jotai/utils';
import { useCallback } from 'react';
type Todo = { id: number; title: string; done: boolean };
const todosAtom = atom<Todo[]>([]);
const nextIdAtom = atom(1);
function AddTodo() {
const addTodo = useAtomCallback(
useCallback((get, set, title: string) => {
const id = get(nextIdAtom);
set(todosAtom, (prev) => [...prev, { id, title, done: false }]);
set(nextIdAtom, id + 1);
}, [])
);
return <button onClick={() => addTodo('Write docs')}>Add</button>;
}
This keeps multi-atom updates consistent and colocated.
Async atoms and React Suspense
Async atoms can fetch data and integrate seamlessly with Suspense.
import { atom, useAtomValue } from 'jotai';
const userIdAtom = atom(1);
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to load user');
return (await res.json()) as { id: number; name: string };
});
function User() {
const user = useAtomValue(userAtom);
return <p>Hello, {user.name}</p>;
}
function Screen() {
return (
<React.Suspense fallback={<p>Loading…</p>}>
<User />
</React.Suspense>
);
}
Prefer Suspense for a clean loading model. If you need to avoid throwing promises, wrap the async atom with a loadable helper:
import { loadable } from 'jotai/utils';
const loadableUserAtom = loadable(userAtom);
function UserNonSuspense() {
const state = useAtomValue(loadableUserAtom);
if (state.state === 'loading') return <p>Loading…</p>;
if (state.state === 'hasError') return <p>Error: {String(state.error)}</p>;
return <p>Hello, {state.data.name}</p>;
}
Scaling with utilities (jotai/utils)
Jotai’s utilities help scale patterns without losing the atomic model.
- atomWithStorage: persist atom values to localStorage/sessionStorage.
import { atomWithStorage } from 'jotai/utils';
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
- atomFamily: generate atoms parameterized by an ID (e.g., multiple documents or tabs).
import { atom, Atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
type Item = { id: string; label: string };
const itemAtomFamily = atomFamily((id: string): Atom<Item | null> => atom(null));
- selectAtom: derive a memoized slice from a larger atom; limits re-renders to changes that pass equality checks.
import { selectAtom } from 'jotai/utils';
const cartAtom = atom({ items: [{ id: 1, price: 10 }] });
const totalAtom = selectAtom(cartAtom, (c) => c.items.reduce((s, i) => s + i.price, 0));
- splitAtom: manage lists by exposing a stable array of per-item atoms—useful for forms and reorderable lists.
import { splitAtom } from 'jotai/utils';
const todosAtom = atom<Todo[]>([]);
const todoAtomsAtom = splitAtom(todosAtom);
Multi-store and Provider scoping
Create isolated stores to scope state to a subtree, test, or SSR request.
import { createStore, Provider } from 'jotai';
import { useMemo } from 'react';
function ScopedArea({ children }: { children: React.ReactNode }) {
const store = useMemo(() => createStore(), []);
return <Provider store={store}>{children}</Provider>;
}
Use cases:
- Multiple editors or dashboards on the same page, each with its own state.
- Storybook stories that shouldn’t bleed state.
- Per-request isolation in SSR frameworks to prevent cross-user leaks.
Performance characteristics
- Granular subscriptions: Components listen to only the atoms they read.
- Derived purity: Derived atoms recompute on demand, not on every render.
- React-friendly: Updates work with concurrent rendering and transitions.
- Memo-friendly: Keep atoms stable (avoid creating them inside renders) to prevent subscription churn.
Tips:
- Prefer useAtomValue and useSetAtom to separate read/write concerns.
- Use selectAtom for large objects; avoid passing big structures through props.
- Batch related writes in useAtomCallback to avoid intermediate renders.
Architecture and patterns
- Feature folders: co-locate atoms with their domain (cart/cart.atoms.ts, user/user.atoms.ts). Export only what consumers need.
- Write-only command atoms: express side-effectful or multi-step updates as write-only atoms. This keeps components declarative.
- Public vs private atoms: prefix private atoms with an underscore and export a minimal surface.
- Composition over configuration: build complex behavior by composing small atoms instead of one “god” store.
TypeScript ergonomics
Atoms are fully typed, and inference usually “just works.” A few tips:
- Parameterize atoms: atom
(initial) for primitives; annotate function atoms when the return isn’t obvious. - For writable derived atoms, type the third argument of the write function (the payload) explicitly.
- Export Atom
types for consumers that only read and WritableAtom for writers when you want stricter boundaries.
import type { Atom } from 'jotai';
export const userAtom: Atom<User | null> = atom<User | null>(null);
Testing atoms and components
- Unit test atoms with a store: set and get atom values without rendering React.
import { createStore } from 'jotai';
const store = createStore();
store.set(countAtom, 5);
expect(store.get(doubledAtom)).toBe(10);
- Component tests: wrap with Provider when you need isolation or seeded state.
import { Provider, createStore } from 'jotai';
const store = createStore();
store.set(countAtom, 3);
render(
<Provider store={store}>
<Counter />
</Provider>
);
SSR and routing
- Define atoms at module scope to keep identities stable across renders.
- For SSR frameworks, create a fresh store per request and render the app under that Provider.
- Hydration: If you seed server values into atoms, mirror the seeding on the client to avoid mismatches.
- Navigation: Atoms live across route changes by default; scope with a Provider if you want per-route resets.
Interop and migration
- From Redux: Map slices to atoms. Derived selectors become derived atoms. Thunks can be replaced by write-only atoms or useAtomCallback.
- From Recoil: Atoms and selectors translate directly; Suspense patterns are similar.
- From Zustand: Localized stores map well to Provider-scoped Jotai stores when you need multiple instances.
Debugging and tooling
- useAtomsDebugValue from devtools packages can surface atom values in React DevTools.
- Log transitions by wrapping writes in command atoms so the intent is visible.
- Keep atom files small; colocation improves discoverability and debugging.
Common pitfalls (and fixes)
- Creating atoms in render: This changes their identity every render. Define atoms outside components or memoize with useMemo when dynamic.
- Infinite async loops: Guard async atoms with stable inputs (e.g., read userIdAtom) and compute conditionally.
- Oversharing big objects: Use selectAtom to subscribe to minimal slices and reduce re-renders.
- Leaking atomFamily instances: If keys are ephemeral, provide a cleanup path (e.g., when unmounting a tab, clear the family instance if held globally).
Best-practice checklist
- Model each domain with a few focused primitive atoms; derive everything else.
- Prefer write-only “command” atoms for complex updates.
- Use Suspense for async; fall back to loadable when needed.
- Scope state with Provider for tests, SSR, or multi-instance UIs.
- Keep atom modules small and typed; export minimal surfaces.
- Profile re-renders and slice subscriptions with selectAtom when scaling.
A minimal real-world example
Putting it together: a tiny cart with persistence and computed totals.
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage, selectAtom } from 'jotai/utils';
type Item = { id: string; name: string; price: number; qty: number };
const cartAtom = atomWithStorage<Item[]>('cart', []);
const addItemAtom = atom(null, (get, set, item: Item) => {
const next = [...get(cartAtom)];
const idx = next.findIndex((i) => i.id === item.id);
if (idx >= 0) next[idx] = { ...next[idx], qty: next[idx].qty + item.qty };
else next.push(item);
set(cartAtom, next);
});
const totalAtom = selectAtom(cartAtom, (items) =>
items.reduce((sum, i) => sum + i.price * i.qty, 0)
);
function CartTotal() {
const total = useAtomValue(totalAtom);
return <strong>Total: ${total.toFixed(2)}</strong>;
}
function AddToCartButton({ item }: { item: Item }) {
const add = useSetAtom(addItemAtom);
return <button onClick={() => add(item)}>Add to cart</button>;
}
Conclusion
Jotai’s atomic approach aligns with React’s strengths: composition, purity, and fine-grained subscriptions. By modeling state as small, testable atoms and composing them with pure derivations, you get predictable behavior, excellent performance, and a codebase that scales without ceremony. Start small, keep atoms focused, and let composition do the hard work.
Related Posts
Flutter Impeller Rendering Engine: A Performance Deep Dive and Tuning Guide
A practical performance deep dive into Flutter’s Impeller renderer: how it works, how to measure it, and tuning patterns for smooth, jank‑free UIs.