jotai
Jotai is an atomic state library for React. State lives in small atom objects rather than one central store: primitive atoms hold values, derived atoms compute from other atoms, and writable atoms model actions. Components subscribe through useAtom, useAtomValue, or useSetAtom, so updates can rerender only the consumers that read the affected atom. A vanilla store API works outside React, while utilities cover storage, families, selection, splitting, reset, hydration, and async values.
Jotai is a strong fit when React state naturally decomposes into a graph of small values and computations. Skip it for ordinary local state or when your team needs the centralized events and operational conventions Redux supplies.
Use it if
- You have shared React state whose dependency graph is easier to express as small composable atoms than reducers or store slices
- You want derived and async state to track dependencies automatically as atoms read other atoms
- You need component-level subscriptions without selectors for every ordinary state read
- You want the same atoms accessed through React hooks and a small imperative store outside React
- Your state is local to one component or a shallow subtree: useState, useReducer, or context avoids another state model
- Your team wants event logs, reducers, middleware conventions, and first-class Redux DevTools workflows; Redux Toolkit gives stronger organization for that style
- You cannot enforce stable atom identity: creating atom() during render without useMemo or useRef can cause an infinite loop because atoms are keyed by object identity
- You render on the server but will rely on the provider-less global store; Jotai's Next.js guide warns that it can share state between requests and recommends a Provider per request tree
- You expect persistence to be invisible during SSR: atomWithStorage starts from the server value and can produce a hydration mismatch when browser storage contains something different
Setup reality
npm install jotai is the entire package install. React is an optional peer in the package metadata because the vanilla entry can run without it, but React hook usage needs React 17 or newer. TypeScript declarations and CommonJS, ESM, and React Native export conditions ship in the package; there is no provider or generated code required for a client-only app. The first real trap is identity: an atom is a configuration object, and recreating it on every render creates new state and can loop. Define atoms at module scope or memoize dynamic ones. Provider-less mode uses a default store. That is convenient in the browser but unsafe for request-scoped server rendering, where one global store may outlive a request, so put Provider around each app request or subtree. Hydrate server values with useHydrateAtoms inside that provider, and remember an atom is normally hydrated once per store. Async read atoms return promises and rely on React Suspense behavior; server rendering cannot simply return unresolved promises in every framework path. Storage utilities need client-safe access. Next.js still prerenders client components, so createJSONStorage must guard window, and UI that changes after localStorage loads may need a client-only boundary to prevent hydration mismatch. Utilities come from jotai/utils, while store-only code can import jotai/vanilla. Large object atoms can still rerender too much; split state, use selectAtom with stable selectors, or use focused derived atoms after measuring. Jotai does not prescribe effects, server-cache invalidation, normalized entities, or action logging, so teams must set those conventions themselves.
Patterns
Create and update a primitive atomcreate-primitive-atom
import { atom, useAtom } from 'jotai'
const countAtom = atom(0)
function Counter() {
const [count, setCount] = useAtom(countAtom)
return <button onClick={() => setCount((n) => n + 1)}>{count}</button>
}Define the atom outside render so its object identity stays stable.
Compute a value from other atomsderive-read-only-state
const priceAtom = atom(20)
const quantityAtom = atom(3)
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom))
function Total() {
const total = useAtomValue(totalAtom)
return <output>{total}</output>
}Dependencies are tracked when the read function calls get and refreshed when it runs again.
Model an action with a write-only atomcreate-write-action
const countAtom = atom(0)
const addAtom = atom(null, (_get, set, amount) => {
set(countAtom, (value) => value + amount)
})
function AddFive() {
const add = useSetAtom(addAtom)
return <button onClick={() => add(5)}>Add five</button>
}useSetAtom avoids subscribing this button to countAtom updates it never reads.
Read asynchronous state with Suspenseload-async-atom
const userIdAtom = atom('42')
const userAtom = atom(async (get) => {
const response = await fetch(`/api/users/${get(userIdAtom)}`)
if (!response.ok) throw new Error('user request failed')
return response.json()
})
function UserName() {
const user = useAtomValue(userAtom)
return <span>{user.name}</span>
}Render this under Suspense and an error boundary; the atom reruns when userIdAtom changes.
Persist an atom to localStoragepersist-browser-state
import { atomWithStorage } from 'jotai/utils'
const themeAtom = atomWithStorage('theme', 'system')
function ThemePicker() {
const [theme, setTheme] = useAtom(themeAtom)
return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme}</button>
}On SSR, the initial server value can differ from localStorage and cause a hydration mismatch.
Scope state with a Providerscope-server-store
import { Provider } from 'jotai'
export function AppState({ children }) {
return <Provider>{children}</Provider>
}Use a request-scoped Provider for SSR instead of letting different requests share the default global store.
Hydrate atoms inside a providerhydrate-server-values
import { Provider } from 'jotai'
import { useHydrateAtoms } from 'jotai/utils'
function Hydrate({ initialUser, children }) {
useHydrateAtoms([[userAtom, initialUser]])
return children
}
<Provider><Hydrate initialUser={user}>{children}</Hydrate></Provider>An atom is normally hydrated only once for a store; remounting with a different value does not automatically overwrite it.
Read and write atoms outside Reactuse-store-outside-react
import { atom, createStore, Provider } from 'jotai'
const onlineAtom = atom(false)
const store = createStore()
const unsubscribe = store.sub(onlineAtom, () => console.log(store.get(onlineAtom)))
store.set(onlineAtom, true)
// <Provider store={store}><App /></Provider>
unsubscribe()Components must use the same store through Provider if imperative updates should reach them.
Create resettable statereset-atom-value
import { useAtom } from 'jotai'
import { atomWithReset, RESET } from 'jotai/utils'
const filterAtom = atomWithReset('all')
const [filter, setFilter] = useAtom(filterAtom)
setFilter('open')
setFilter(RESET)RESET is a special symbol exported by jotai/utils and restores the atom's original value.
Create a prop-dependent atom safelymemoize-dynamic-atom
function Item({ id }) {
const itemAtom = useMemo(
() => atom((get) => get(itemsAtom).find((item) => item.id === id)),
[id],
)
const item = useAtomValue(itemAtom)
return <span>{item?.name}</span>
}Calling atom directly on every render creates a different config and can trigger an infinite update loop.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zustand | npm | You prefer one small hook-based store with actions and selectors instead of an atom graph |
| valtio | npm | You want mutable proxy state with automatic snapshot subscriptions |
| @reduxjs/toolkit | npm | You need reducers, middleware, event visibility, established team conventions, and a large integration ecosystem |