Jotai Reference: Atoms, Derived State, Async Atoms, Storage & vs Zustand
Jotai is an atomic state management library for React. Unlike Zustand (one store with selectors), Jotai splits state into small atoms. Components subscribe to individual atoms and only re-render when that specific atom changes. No context providers needed (unless you want scoped state). The mental model is similar to React’s useState but globally shared. Jotai vs Zustand: Jotai is better for highly granular state (each UI widget has its own atom); Zustand is better for structured domain state (users, cart, auth in one store).
1. atom() & useAtom
Create atoms, useAtom hook, read/write atoms, and atom types
// npm install jotai
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
// Primitive atom — like useState but globally shared:
const countAtom = atom(0);
const nameAtom = atom("Alice");
const darkAtom = atom(false);
const listAtom = atom<string[]>([]);
// useAtom — [value, setter] like useState:
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={(c) => setCount((c) => c - 1)}>-</button>
</div>
);
}
// Read-only — no re-render on set:
function Display() {
const count = useAtomValue(countAtom); // read only
return <span>{count}</span>;
}
// Write-only — no re-render on read:
function IncrementButton() {
const setCount = useSetAtom(countAtom); // write only
return <button onClick={() => setCount((c) => c + 1)}>+</button>;
}
// Atom family — parameterized atoms (one per ID):
import { atomFamily } from "jotai/utils";
const todoAtom = atomFamily((id: string) => atom({ id, done: false, text: "" }));
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtom(id)); // atom per todo item
return <div onClick={() => setTodo((t) => ({ ...t, done: !t.done }))}>{todo.text}</div>;
}
2. Derived (Computed) Atoms
Read-only derived atoms, read-write derived atoms, and async atoms
import { atom } from "jotai";
const countAtom = atom(0);
const multiplierAtom = atom(2);
// Read-only derived atom (computed from other atoms):
const doubledAtom = atom((get) => get(countAtom) * get(multiplierAtom));
// Re-evaluates when countAtom or multiplierAtom changes
const itemsAtom = atom<Item[]>([]);
const filterAtom = atom<"all" | "done" | "pending">("all");
const filteredAtom = atom((get) => {
const items = get(itemsAtom);
const filter = get(filterAtom);
if (filter === "done") return items.filter((i) => i.done);
if (filter === "pending") return items.filter((i) => !i.done);
return items;
});
// Read-write derived atom — read from one, write to another:
const uppercaseNameAtom = atom(
(get) => get(nameAtom).toUpperCase(), // getter
(_get, set, value: string) => // setter
set(nameAtom, value.toLowerCase()) // writes to original atom
);
// Async atom — fetches data:
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<User>;
});
// Use with Suspense: <Suspense fallback={<Spinner />}><UserProfile /></Suspense>
// In component:
const user = useAtomValue(userAtom); // suspends until resolved
3. atomWithStorage, atomWithReset & Utils
Persist atoms to localStorage, reset to initial value, and loadable for async
import { atomWithStorage, atomWithReset, RESET } from "jotai/utils";
import { loadable } from "jotai/utils";
// Persist to localStorage:
const themeAtom = atomWithStorage("theme", "light");
// Reads from localStorage on mount, writes on change
// atomWithStorage("key", initialValue, storage?, options?)
// Custom storage (e.g., sessionStorage):
const sessionAtom = atomWithStorage("session-data", null, sessionStorage);
// atomWithReset — can reset to initial value:
const inputAtom = atomWithReset("");
function SearchInput() {
const [value, setValue] = useAtom(inputAtom);
return (
<div>
<input value={value} onChange={(e) => setValue(e.target.value)} />
<button onClick={() => setValue(RESET)}>Clear</button>
</div>
);
}
// loadable — handles async atoms without Suspense:
const loadableUserAtom = loadable(userAtom);
function UserDisplay() {
const state = useAtomValue(loadableUserAtom);
if (state.state === "loading") return <Spinner />;
if (state.state === "hasError") return <p>Error: {String(state.error)}</p>;
return <p>{state.data.name}</p>; // state.state === "hasData"
}
4. Provider, Scoped Atoms & DevTools
Provider for isolated state, store API for testing, and Jotai DevTools
import { Provider, createStore, useStore } from "jotai";
// By default: atoms are globally shared (no Provider needed)
// Provider creates an isolated scope — useful for testing or multiple instances:
function TestWrapper({ children }) {
return (
<Provider>
{children} {/* atoms here are isolated from other Providers */}
</Provider>
);
}
// createStore — for testing (read/write atoms outside React):
const store = createStore();
store.set(countAtom, 10);
store.get(countAtom); // 10
store.sub(countAtom, () => console.log("changed")); // subscribe
// Reset specific atoms in tests:
import { createStore } from "jotai";
let store: ReturnType<typeof createStore>;
beforeEach(() => {
store = createStore();
// use <Provider store={store}> in render
});
// Jotai DevTools (browser extension — like Redux DevTools):
import { useAtomsDevtools } from "jotai-devtools";
// In your app root: <AtomsDevtools /> (dev only)
// atomWithObservable — integrate RxJS or other observables:
import { atomWithObservable } from "jotai/utils";
import { interval } from "rxjs";
const clockAtom = atomWithObservable(() => interval(1000));
5. Jotai vs Zustand & Performance Patterns
When to choose Jotai vs Zustand, performance optimization, and Next.js SSR
// Jotai vs Zustand:
// Use JOTAI when:
// - Fine-grained subscriptions (each widget/component has isolated state)
// - Derived state is complex (chains of computed values)
// - Async data per component (async atoms + Suspense)
// - Atom composition feels natural for your data shape
// - State is more "spreadsheet-like" (cells with formulas)
// Use ZUSTAND when:
// - Domain model state (users, cart, auth, settings in one place)
// - Actions + reducers pattern feels natural
// - Middleware needed (persist, devtools, immer)
// - Team familiar with Redux patterns
// Granular re-renders — key Jotai advantage:
// Each useAtom(xAtom) subscribes ONLY to xAtom
// Component with useAtomValue(countAtom) re-renders ONLY when count changes
// Compare: useStore() in Zustand re-renders on ANY store change (fix with selector)
// Next.js SSR (avoid atom state leaking between requests):
// npm install jotai-ssr (or use Provider per request)
import { createStore } from "jotai";
function getServerSideProps() {
const store = createStore();
store.set(countAtom, 10); // server-side init
return { props: { initialState: store.get(countAtom) } };
}
// Client: hydrate atoms from props inside Provider
// Atom scope — isolate atoms per component instance:
import { ScopeProvider } from "jotai-scope";
// Multiple instances of the same component with isolated state
<ScopeProvider atoms={[countAtom]}><Counter /></ScopeProvider>
<ScopeProvider atoms={[countAtom]}><Counter /></ScopeProvider>
// Each Counter has its own countAtom instance
Track Node.js and React ecosystem releases at ReleaseRun. Related: Zustand Reference | TanStack Query Reference | React Reference | React EOL Tracker
🔍 Free tool: npm Package Health Checker — check Jotai and React state management packages for known CVEs and active maintenance.
Founded
2023 in London, UK
Contact
hello@releaserun.com