Jotai: Atomic State and Async Atoms with Suspense
Jotai inverts the mental model of “one big store.” Instead of lifting a single object to the top and carving it up with selectors, you define small units of state—atoms—and declare how they relate. Components subscribe only to the atoms they read, and derived values are ordinary dependencies expressed as functions of other atoms.
That bottom-up composition shines when your UI is a lattice of interdependent values: a chart whose series depends on filters, a multi-step flow where later steps validate against earlier atoms, or localized async resources that should suspend independently. It is less compelling when you truly have a flat bag of unrelated toggles; a single Zustand store may be simpler to navigate.
Primitives, derived atoms, and read-write views
A primitive atom holds a value. A derived atom reads other atoms through get and recomputes when
its dependencies change. A read-write derived atom can project one atom through a lens—here,
uppercasing for display while normalizing writes back to lowercase storage.
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
const countAtom = atom(0);
const nameAtom = atom<string>("");
const doubledCountAtom = atom((get) => get(countAtom) * 2);
const uppercaseNameAtom = atom(
(get) => get(nameAtom).toUpperCase(),
(_get, set, newValue: string) => set(nameAtom, newValue.toLowerCase()),
);
The important design choice is granularity. Atoms that change together might belong as one atom; atoms that update on different cadences should split so subscribers do not over-rerender.
Async atoms and Suspense
Jotai treats promise-returning read functions as first-class. When a component reads such an atom, React suspends until the promise resolves—so loading boundaries stay colocated with the subtree that needs the data.
import { atom, useAtomValue } from "jotai";
interface User {
name: string;
}
const userAtom = atom(async () => {
const response = await fetch("/api/user");
return response.json() as Promise<User>;
});
function UserProfile() {
const user = useAtomValue(userAtom);
return <div>{user.name}</div>;
}
This pattern pairs naturally with <Suspense> and error boundaries. It does not replace TanStack
Query for general server-cache concerns—deduplication across routes, mutation lifecycles, and
background refetch policies are still Query’s core competence—but for scoped async UI state that
should suspend cleanly, async atoms are expressive.
Parameterized state with atomFamily
Lists and detail panes often need one atom per entity id. atomFamily memoizes atom instances
so each id gets a stable atom, which matters for equality and subscription stability.
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
declare function fetchUser(userId: string): Promise<{ id: string; name: string }>;
const userByIdAtom = atomFamily((userId: string) => atom(async () => fetchUser(userId)));
Use families when identity is part of the state key; avoid creating fresh atoms inside render without a family or memoization, or you will lose subscription deduplication.
Scoped stores with Provider
Jotai’s default store is global, but you can isolate a subtree by mounting a fresh Provider.
That is useful for modals embedding editable drafts, storybook-style fixtures, or parallel wizards
that must not leak atoms into siblings.
import { Provider } from "jotai";
function ComponentThatUsesAtoms() {
return null;
}
function IsolatedComponent() {
return (
<Provider>
<ComponentThatUsesAtoms />
</Provider>
);
}
Treat scoped providers like lexical scope: powerful for encapsulation, confusing if overused without documentation.
Read-only and write-only hooks
Jotai exposes hooks that split read and write traffic. A control that only increments does not need to rerender when the count changes; a display that only shows the count does not need the setter.
import { useAtomValue, useSetAtom } from "jotai";
declare const countAtom: import("jotai").Atom<number>;
const setCountAtom = useSetAtom(countAtom);
const count = useAtomValue(countAtom);
This mirrors the Zustand selector lesson at a different granularity: subscribe to the minimum surface.
Jotai versus Zustand: a judgment call, not a popularity contest
Jotai tends to win when:
- State is naturally decomposed into many small cells with nontrivial dependencies.
- You want fine-grained subscriptions without manually listing selectors.
- Async resources should suspend as part of the atom graph.
Zustand tends to win when:
- You have a single cohesive domain model updated through a handful of actions.
- The team prefers a familiar “store object + actions” mental map.
- You want the smallest API for global UI that rarely composes fractally.
Both libraries are small compared to classic Redux ecosystems; the cost of choosing wrong is usually refactorable. The expensive mistake is using either one for server cache or URL-addressable state—those concerns already have better owners.
Jotai rewards you for thinking like a dependency graph. If your UI problems sound like graph problems—shared inputs, derived outputs, parameterized nodes—atoms will feel like the right algebra. If they sound like “a few booleans and a user id,” keep the solution proportionate.
Suspense, errors, and store lifetime
Async atoms suspend, but they still live in a world of failed networks and aborted requests.
Pair suspense boundaries with error boundaries that can reset atom state or navigate to a
recovery route; for user-triggered retries, expose a write-only atom or callback that invalidates
the async atom’s promise source. Because Jotai’s default store is global, long-lived async atoms can
accidentally retain stale closures if you capture mutable module state—prefer passing parameters
through atomFamily or deriving from atoms instead of from changing module variables. When you
mount a subtree Provider, remember that you have created a separate universe of atom values:
great for isolation, easy to misuse if half the app reads the global store and half reads a scoped
one without a documented rule.
Composition with other tools
Jotai does not remove the need for TanStack Query when you want shared cache semantics across routes, mutation lifelines, and background refresh. A pragmatic split is: Query owns authoritative server collections and invalidation policies; Jotai owns view-local derived inputs, UI drafts, and async reads that should suspend inside a single feature without polluting global query keys. If you find yourself encoding API pagination cursors into atoms while the same list is also fetched by Query, stop and reconcile—either the list is server state or it is client-local, rarely both with equal claim to truth.