TL;DR: Jotai is atomic state management for React. Define small
atom()units, useuseAtom()in any component - no Provider needed. Components re-render only when their specific atoms change. Simpler than Redux, lighter than Recoil. Official docs: jotai.org.
What is Jotai?
Jotai implements the atomic state model - state is broken into small independent units called atoms. Components subscribe to exactly the atoms they need, so a change to one atom only re-renders components that use that atom. No selectors, no context providers, no boilerplate.
When to use it
Use Jotai when:
- You have many small, independent pieces of global state (e.g., per-entity UI state)
- You want fine-grained re-renders without manually writing selectors
- You like Recoil's model but want something lighter and better maintained
Jotai vs Zustand: Zustand is store-first (one object per domain); Jotai is atom-first. Zustand is simpler to reason about for most teams. Jotai shines when you have many orthogonal pieces of state that rarely interact.
Key Features
- Atoms - the smallest unit of state; each is a
useStateequivalent - Derived atoms - compute values from other atoms without memoization boilerplate
- Async atoms - atoms can be async; Jotai integrates with React Suspense
- Atom families - create parameterised atoms (e.g., per-item state)
- jotai/utils -
atomWithStorage,atomWithReset,selectAtom,splitAtom
Installation
npm install jotai
Basic Usage
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
// Define atoms (outside components - these are module-level constants)
const countAtom = atom(0)
const doubledAtom = atom((get) => get(countAtom) * 2) // derived
function Counter() {
const [count, setCount] = useAtom(countAtom)
const doubled = useAtomValue(doubledAtom)
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<p>Doubled: {doubled}</p>
</div>
)
}
// Component that only writes - doesn't re-render on change
function ResetButton() {
const setCount = useSetAtom(countAtom)
return <button onClick={() => setCount(0)}>Reset</button>
}
Atom Families (Per-Item State)
import { atomFamily } from 'jotai/utils'
// Create a separate atom for each todo item by ID
const todoAtomFamily = atomFamily(
(id: string) => atom({ id, completed: false, text: '' })
)
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id))
return (
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodo(t => ({ ...t, completed: !t.completed }))}
/>
{todo.text}
</label>
)
}
Async Atoms with Suspense
import { atom } from 'jotai'
import { Suspense } from 'react'
const userAtom = atom(async () => {
const res = await fetch('/api/me')
return res.json() as Promise<User>
})
function Profile() {
const user = useAtomValue(userAtom) // Suspense required above
return <h1>{user.name}</h1>
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<Profile />
</Suspense>
)
}
Common Gotchas
- Define atoms outside components - atoms defined inside a component create a new atom on every render, which defeats the purpose
- No Provider needed - but you can use
<Provider>to scope atom state to a subtree (useful for tests or isolated widgets) useAtomValuevsuseAtom- preferuseAtomValuewhen you only read; preferuseSetAtomwhen you only write - both skip unnecessary re-renders
Why atomic state cuts re-renders
The whole pitch for Jotai comes down to one thing: components subscribe to atoms, not to a big store. With a single store object, any change forces every subscriber to run a selector and bail out. Jotai flips that. If your themeAtom changes, only the components that actually read themeAtom re-render. Everything else stays untouched.
This matters most in dashboards and editors where dozens of small, unrelated values live on screen at once. Picture a settings panel with 30 toggles. With Jotai, flipping one toggle re-renders one row. You don't write a single selector to get that behavior - it falls out of the atom model for free.
Derived atoms keep the same property. A derived atom only recomputes when one of its dependencies changes, and it only pushes updates to components that read the derived value. In my experience this removes a whole class of "why is this re-rendering" debugging sessions that you get with context-based state.
Reading and writing patterns you'll actually use
Read-write atoms cover most app state, but Jotai also supports write-only atoms for actions. A write-only atom takes null as its read value and a write function as its second argument. You call it like a reducer dispatch, which keeps your update logic in one place instead of scattered across components.
import { atom, useSetAtom } from 'jotai'
const countAtom = atom(0)
// Write-only "action" atom
const incrementAtom = atom(null, (get, set, by: number) => {
set(countAtom, get(countAtom) + by)
})
function Buttons() {
const increment = useSetAtom(incrementAtom)
return (
<>
<button onClick={() => increment(1)}>+1</button>
<button onClick={() => increment(10)}>+10</button>
</>
)
}
This pattern scales nicely. You keep business rules inside the action atom, and components stay dumb. They fire the action and never touch the raw state directly.
Persistence and storage
The jotai/utils package ships atomWithStorage, which syncs an atom to localStorage, sessionStorage, or any storage you pass in. It reads the initial value on mount and writes back on every change. Theme preference, sidebar collapse state, and recently viewed items are all good candidates.
import { atomWithStorage } from 'jotai/utils'
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')
One honest caveat: server-side rendering needs care. The stored value isn't available during the first server render, so you can hit hydration mismatches. The usual fix is to render a neutral default until the component mounts, then read the stored value. Don't skip this step or React will warn you in the console.
Testing atoms in isolation
Because atoms work without a global setup, tests stay simple. Wrap the component under test in a fresh <Provider> to get an isolated atom store per test. Each test starts clean, so state from one case can't bleed into the next. You can also hydrate a Provider with initial atom values, which is handy for rendering a component in a specific state without clicking through the UI first.
Related
- Zustand - store-first alternative; Zustand is better for domain objects, Jotai for fine-grained per-value subscriptions
- TanStack Query - Jotai handles client state; TanStack Query handles server/async state
- TypeScript Generics Guide -
atom<T>()is generic; typed atoms prevent runtime errors in complex state graphs