Skip to content

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.