Jotai: Atomic State That Stays Out of Your Way

December 16, 2025

|repo-review

by Florian Narr

Jotai: Atomic State That Stays Out of Your Way

What it does

Jotai is an atomic state management library for React. You create atoms — small, independent units of state — and compose them into derived values. No providers required for the default store, no string keys, no boilerplate. The core is 2kb minzipped with zero runtime dependencies.

Why I starred it

Most React state libraries force you to think in terms of a single store or normalized shapes. Jotai flips that. Each atom is its own thing. You compose them bottom-up. The API surface is tiny — atom() and useAtom() cover 90% of use cases — but the internals are surprisingly sophisticated. The store tracks dependencies automatically and recomputes only what changed, using a topological sort that most users will never see.

It also ships with first-class async support. Derived atoms can return promises, and Jotai integrates with React Suspense out of the box. That alone sets it apart from most alternatives.

How it works

The entry point is atom() in src/vanilla/atom.ts. It's a single function with five overloads covering primitive atoms, read-only derived atoms, writable derived atoms, and write-only atoms. Every atom gets a monotonically increasing key:

let keyCount = 0

export function atom(read?, write?) {
  const key = `atom${++keyCount}`
  const config = {
    toString() {
      return import.meta.env?.MODE !== 'production' && this.debugLabel
        ? key + ':' + this.debugLabel
        : key
    },
  }
  if (typeof read === 'function') {
    config.read = read
  } else {
    config.init = read
    config.read = defaultRead
    config.write = defaultWrite
  }
  if (write) config.write = write
  return config
}

The defaultRead for primitive atoms is just (get) => get(this) — the atom reads itself. The defaultWrite applies a SetStateAction pattern identical to useState. That's the entire atom creation. No classes, no decorators, no registration. An atom is a plain object with read and write functions.

The real complexity lives in src/vanilla/internals.ts — 1153 lines that implement the store. The store state is a tuple called BuildingBlocks with 29 indexed slots:

type BuildingBlocks = [
  atomStateMap: AtomStateMap,          // 0
  mountedMap: MountedMap,              // 1
  invalidatedAtoms: InvalidatedAtoms,  // 2
  changedAtoms: ChangedAtoms,          // 3
  // ... 25 more slots
]

Each atom gets an AtomState tracking its dependencies (d), pending promises (p), epoch number (n), and cached value (v) or error (e). When you write to an atom, the store marks dependents as invalidated, then runs BUILDING_BLOCK_recomputeInvalidatedAtoms — a topological sort via iterative DFS starting at line 472:

const stackAtoms: AnyAtom[] = []
const stackStates: AtomState[] = []
for (const atom of changedAtoms) {
  stackAtoms.push(atom)
  stackStates.push(ensureAtomState(store, atom))
}
while (stackAtoms.length) {
  // DFS traversal, pushing dependents onto stack
  // Then iterate in reverse for topological order
}

It traverses the dependency graph, builds a reverse-topological list, then walks it backwards to recompute only atoms whose dependencies actually changed. The changedAtoms set acts as the frontier. This means updating one atom in a graph of thousands only touches the affected subgraph.

The readAtomState function at line 564 has a smart caching strategy. If an atom is mounted (has a subscriber), it trusts the cache because dependency invalidation keeps it up to date. If it's unmounted, it falls back to a store-wide epoch number to decide staleness. This two-tier approach avoids unnecessary recomputation for both active and dormant atoms.

On the React side, src/react/useAtomValue.ts uses useReducer — not useState — to subscribe to atom changes. The reducer compares identity with Object.is and short-circuits when nothing changed. It also handles async atoms by wrapping promises with a "continuable promise" pattern that chains abort handlers, so if a dependency changes mid-flight, the old fetch gets superseded without race conditions.

Using it

import { atom, useAtom } from 'jotai'

// Primitive atom
const countAtom = atom(0)

// Derived atom — recomputes when countAtom changes
const doubledAtom = atom((get) => get(countAtom) * 2)

// Async derived atom — works with Suspense
const dataAtom = atom(async (get) => {
  const id = get(countAtom)
  const res = await fetch(`/api/items/${id}`)
  return res.json()
})

// Write-only atom for actions
const incrementAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1)
})

The utils in src/vanilla/utils/ add patterns you'll eventually need: atomWithStorage for persistence, selectAtom for memoized selectors, splitAtom for working with arrays. splitAtom alone is 216 lines — it creates individual atoms for each array element so updating one item doesn't rerender the entire list.

Rough edges

The BuildingBlocks tuple with numeric indices (slot 0, slot 6, slot 26) makes the internals hard to follow. It's an intentional performance choice — array access is faster than object property lookup — but it means debugging store behavior requires constant cross-referencing with the type definition.

atomFamily is deprecated and slated for removal in v3, pushed to a separate jotai-family package. If you're building anything with parameterized atoms, that's a dependency to track.

The 49 test files cover the core well, but the single-commit shallow history on the main branch hides the project's evolution. Documentation lives entirely on jotai.org — the README is mostly example snippets. If you want to understand the epoch-based invalidation or how StoreHooks work, you're reading source.

The store detects multiple Jotai instances via globalThis.__JOTAI_DEFAULT_STORE__ and warns in development. Good defensive engineering, but it hints at a real pain point in monorepo setups where duplicate packages slip in.

Bottom line

If you want React state management that scales from useState replacement to complex derived state graphs without ceremony, Jotai is the one to pick. The atomic model and automatic dependency tracking mean you spend time on your state logic, not fighting the library.

pmndrs/jotai on GitHub
pmndrs/jotai