Jotai Atoms
Jotai takes a fundamentally different approach to state management. Instead of one global store, state is broken into individual atoms — tiny, independent pieces of state. Components subscribe to exactly the atoms they need, which means re-renders are surgical: updating one atom only re-renders components that read that atom.
Install Jotai with:
npm install jotai
Creating and Reading Atoms
An atom is created with atom(initialValue). Use useAtom in a component to get a [value, setValue] pair — exactly like useState, but the state lives outside the component and is shared across the entire app:
import { atom, useAtom } from 'jotai'
// Define atoms at module scope — they are singletons
export const countAtom = atom(0)
export const nameAtom = atom('Alice')import { useAtom } from 'jotai'
import { countAtom } from './atoms'
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+</button>
<button onClick={() => setCount((c) => c - 1)}>-</button>
</div>
)
}
// This component reads the SAME countAtom — no prop drilling needed
function CountDisplay() {
const [count] = useAtom(countAtom)
return <span>Current: {count}</span>
}useAtomValue and useSetAtom
When a component only reads or only writes an atom, use the more focused hooks to avoid unnecessary re-renders:
import { useAtomValue, useSetAtom } from 'jotai'
import { countAtom } from './atoms'
// Only re-renders when countAtom changes
function ReadOnlyDisplay() {
const count = useAtomValue(countAtom)
return <p>The count is {count}</p>
}
// NEVER re-renders due to countAtom changes
function IncrementButton() {
const setCount = useSetAtom(countAtom)
return <button onClick={() => setCount((c) => c + 1)}>Increment</button>
}Derived Atoms
Pass a function to atom() to create a derived atom — one whose value is computed from other atoms. The function receives a get utility to read any atom. Derived atoms are read-only by default:
import { atom } from 'jotai'
export const countAtom = atom(0)
export const multiplierAtom = atom(2)
// Derived: automatically recomputes when countAtom or multiplierAtom changes
export const resultAtom = atom((get) => get(countAtom) * get(multiplierAtom))
// Multiple dependencies are fine
export const summaryAtom = atom(
(get) => `Count is ${get(countAtom)}, result is ${get(resultAtom)}`
)function ResultDisplay() {
const result = useAtomValue(resultAtom)
const summary = useAtomValue(summaryAtom)
return (
<div>
<p>Result: {result}</p>
<p>{summary}</p>
</div>
)
}Read-Write Derived Atoms
Provide both a read function and a write function to create a two-way derived atom — useful for transformations or form values:
// Temperature in Celsius stored as source of truth
const celsiusAtom = atom(0)
// Derived atom that converts to/from Fahrenheit
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 1.8 + 32,
(_get, set, fahrenheit: number) => {
set(celsiusAtom, (fahrenheit - 32) / 1.8)
}
)
function TemperatureConverter() {
const [celsius, setCelsius] = useAtom(celsiusAtom)
const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom)
return (
<div>
<input
type="number"
value={celsius}
onChange={(e) => setCelsius(Number(e.target.value))}
placeholder="Celsius"
/>
<input
type="number"
value={fahrenheit}
onChange={(e) => setFahrenheit(Number(e.target.value))}
placeholder="Fahrenheit"
/>
</div>
)
}Async Atoms
An atom whose read function returns a Promise is an async atom. Reading it suspends the component until the Promise resolves — use React Suspense to handle the loading state:
import { atom } from 'jotai'
const userIdAtom = atom(1)
// This atom fetches automatically when userIdAtom changes
const userAtom = atom(async (get) => {
const id = get(userIdAtom)
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
})import { Suspense } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
function UserProfile() {
// Suspends while the async atom resolves
const user = useAtomValue(userAtom)
return <p>{user.name} — {user.email}</p>
}
function UserIdPicker() {
const setUserId = useSetAtom(userIdAtom)
return (
<div>
{[1, 2, 3].map((id) => (
<button key={id} onClick={() => setUserId(id)}>User {id}</button>
))}
</div>
)
}
function App() {
return (
<div>
<UserIdPicker />
<Suspense fallback={<p>Loading user…</p>}>
<UserProfile />
</Suspense>
</div>
)
}atomFamily — Parameterized Atoms
atomFamily creates a factory that returns a unique atom per parameter value. This is ideal for per-entity state — each item in a list gets its own atom:
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
// One atom per todo ID
const todoAtomFamily = atomFamily((id: number) =>
atom({ id, text: '', completed: false })
)
// Usage: todoAtomFamily(1), todoAtomFamily(2) — each is independentComplete Todos App
import { atom } from 'jotai'
interface Todo {
id: number
text: string
completed: boolean
}
export const todosAtom = atom<Todo[]>([
{ id: 1, text: 'Learn Jotai', completed: false },
{ id: 2, text: 'Build something', completed: false },
])
export const filterAtom = atom<'all' | 'active' | 'done'>('all')
export const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom)
const filter = get(filterAtom)
if (filter === 'active') return todos.filter((t) => !t.completed)
if (filter === 'done') return todos.filter((t) => t.completed)
return todos
})
export const completedCountAtom = atom(
(get) => get(todosAtom).filter((t) => t.completed).length
)import { useAtom, useAtomValue, useSetAtom } from 'jotai'
function TodoList() {
const filtered = useAtomValue(filteredTodosAtom)
const [todos, setTodos] = useAtom(todosAtom)
const [filter, setFilter] = useAtom(filterAtom)
const toggle = (id: number) =>
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
)
const add = (text: string) =>
setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }])
return (
<div>
<div>
{(['all', 'active', 'done'] as const).map((f) => (
<button key={f} onClick={() => setFilter(f)}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}>
{f}
</button>
))}
</div>
<ul>
{filtered.map((todo) => (
<li key={todo.id} onClick={() => toggle(todo.id)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</li>
))}
</ul>
</div>
)
}Jotai vs Zustand — When to Choose Each
Aspect | Jotai | Zustand |
|---|---|---|
Mental model | Individual atoms (bottom-up) | Single store (top-down) |
Re-render granularity | Per-atom (very fine) | Per-selector |
Suspense integration | First-class (async atoms) | Requires manual handling |
Per-entity state | Natural (atomFamily) | Requires careful selectors |
Bundle size | ~3 kB | ~3 kB |
DevTools | Jotai DevTools extension | Redux DevTools via middleware |
Provider required | No (optional) | No |
Use Jotai when state is naturally atomic — UI toggles, per-item selection, async data per entity.
Use Zustand when state is a cohesive object — a cart, a user session, a complex form.
Jotai pairs naturally with React Suspense for async data — async atoms compose cleanly.
Both libraries are tiny and work without a Provider, so choosing the wrong one is not a disaster.