Introduction to Jotai
Jotai is a primitive, flexible state management library for React that takes an atomic approach to global state. Unlike Redux or Zustand, which typically use a single store with reducers or hooks, Jotai lets you define small, composable units of state called atoms. Each atom holds a piece of state, and components subscribe only to the atoms they care about, which leads to fine-grained reactivity and minimal re-renders.
Created by Daishi Kato, Jotai is inspired by Recoil but is smaller, more flexible, and has a thriving ecosystem of integration libraries. It works exceptionally well for applications that need both simple local state and complex derived, asynchronous, or persistent state.
Why Jotai Matters
Most state management solutions force you to choose between a top-down model (Redux) or a hook-based model (Zustand). Jotai offers a third path: a bottom-up model where state is composed from atoms. This matters because:
- Fine-grained subscriptions: Components re-render only when the atoms they read change.
- No boilerplate: No actions, reducers, or selectors required for simple cases.
- Composability: Derived atoms can depend on other atoms, creating a reactive graph.
- Async-first: Atoms can hold promises, suspending components naturally.
- Framework-agnostic core: The
jotai/vanillapackage works outside React.
Core Concepts
Atoms and Hooks
An atom is the smallest unit of state in Jotai. You create one with atom() and read or write it inside components using the useAtom hook. The initial value can be any JavaScript value, including a function that returns the initial value.
import { atom, useAtom } from 'jotai'
const countAtom = atom(0)
function Counter() {
const [count, setCount] = useAtom(countAtom)
return (
<button onClick={() => setCount((c) => c + 1)}>
{count}
</button>
)
}
If a component only needs to read the value, use useAtomValue. If it only needs to update it, use useSetAtom. This avoids unnecessary re-renders when the value changes but the component doesn't display it.
import { useAtomValue, useSetAtom } from 'jotai'
const themeAtom = atom('light')
function ThemeDisplay() {
const theme = useAtomValue(themeAtom)
return <p>Current theme: {theme}</p>
}
function ThemeToggle() {
const setTheme = useSetAtom(themeAtom)
return (
<button onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}>
Toggle theme
</button>
)
}
Derived Atoms
A derived atom is one whose value depends on other atoms. You create it by passing a read function to atom(). Jotai automatically tracks dependencies and recomputes the derived atom whenever its inputs change.
const priceAtom = atom(100)
const quantityAtom = atom(3)
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom))
function CartTotal() {
const total = useAtomValue(totalAtom)
return <p>Total: ${total}</p>
}
You can also create writable derived atoms by passing both a read and a write function. The write function receives a set callback, a get callback, and the new value.
const dollarsAtom = atom(100)
const eurosAtom = atom(
(get) => get(dollarsAtom) * 0.92,
(get, set, newEuros) => set(dollarsAtom, newEuros / 0.92)
)
Common Patterns
atomFamily for Parameterized State
Sometimes you need a collection of atoms keyed by some parameter, such as a list of items by ID. The atomFamily utility generates atoms on demand and caches them, so the same parameter always returns the same atom instance.
import { atomFamily } from 'jotai/utils'
const todoAtomFamily = atomFamily((id: string) =>
atom({ id, text: '', completed: false })
)
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id))
return (
<li>
<input
value={todo.text}
onChange={(e) => setTodo({ ...todo, text: e.target.value })}
/>
</li>
)
}
Be careful with atomFamily because atoms are never automatically removed from the cache. If you generate atoms for transient IDs, you should call todoAtomFamily.remove(id) when the item is deleted, or use todoAtomFamily.setShouldRemove(true) to clear the entire cache.
Persistent State with atomWithStorage
The atomWithStorage helper synchronizes an atom with localStorage, sessionStorage, or any custom storage backend that implements the Storage interface. This is the simplest way to persist user preferences, theme, or authentication tokens across sessions.
import { atomWithStorage } from 'jotai/utils'
const darkModeAtom = atomWithStorage('darkMode', false)
function App() {
const [darkMode, setDarkMode] = useAtom(darkModeAtom)
return (
<div className={darkMode ? 'dark' : 'light'}>
<button onClick={() => setDarkMode(!darkMode)}>
Toggle dark mode
</button>
</div>
)
}
For server-side rendering, atomWithStorage handles the absence of window gracefully, returning the default value during SSR and hydrating from storage on the client.
Asynchronous Atoms
Jotai supports async atoms natively. If an atom's read function returns a promise, any component reading that atom will suspend until the promise resolves. This pairs perfectly with React Suspense and error boundaries.
const userIdAtom = atom(1)
const userAtom = atom(async (get) => {
const id = get(userIdAtom)
const res = await fetch(`/api/users/${id}`)
return res.json()
})
function UserProfile() {
const user = useAtomValue(userAtom)
return <div>{user.name}</div>
}
// Wrap in Suspense somewhere up the tree:
// <Suspense fallback={<Spinner />}>
// <UserProfile />
// </Suspense>
For mutations that should refresh an async atom, you can use a writable derived atom that updates a "trigger" atom, forcing the async atom to recompute.
const refreshTriggerAtom = atom(0)
const userAtom = atom(async (get) => {
get(refreshTriggerAtom) // dependency for refresh
const res = await fetch('/api/user')
return res.json()
})
const refreshUserAtom = atom(null, (_get, set) => {
set(refreshTriggerAtom, (n) => n + 1)
})
Splitting Arrays with splitAtom
When you have an array of items in a single atom and want each item to be its own atom, splitAtom is the right tool. It returns a list of atoms, one per item, and lets you update or remove individual items without touching the rest of the array.
import { splitAtom } from 'jotai/utils'
const todosAtom = atom<{ id: string; text: string }[]>([
{ id: '1', text: 'Learn Jotai' },
{ id: '2', text: 'Build an app' },
])
const todoAtomsAtom = splitAtom(todosAtom)
function TodoList() {
const [todoAtoms, dispatch] = useAtom(todoAtomsAtom)
return (
<ul>
{todoAtoms.map((todoAtom) => (
<TodoItem key={todoAtom} todoAtom={todoAtom} dispatch={dispatch} />
))}
</ul>
)
}
function TodoItem({ todoAtom, dispatch }) {
const [todo, setTodo] = useAtom(todoAtom)
return (
<li>
<input
value={todo.text}
onChange={(e) => setTodo({ ...todo, text: e.target.value })}
/>
<button onClick={() => dispatch({ type: 'remove', atom: todoAtom })}>
Delete
</button>
</li>
)
}
Integration Libraries
Jotai's ecosystem includes a set of official integration packages that extend its core with specialized features. These libraries are published under the jotai- prefix and are maintained alongside the main package.
jotai-immer for Immutable Updates
Updating nested state objects can be verbose with the spread operator. The jotai-immer package lets you mutate state directly inside the setter using Immer's produce mechanism, while keeping the underlying state immutable.
import { atomWithImmer } from 'jotai-immer'
const formAtom = atomWithImmer({
user: { name: '', email: '' },
errors: {},
})
function ContactForm() {
const [form, setForm] = useAtom(formAtom)
return (
<input
value={form.user.name}
onChange={(e) =>
setForm((draft) => {
draft.user.name = e.target.value
})
}
/>
)
}
jotai-query for React Query Integration
If you already use TanStack Query for server state, jotai-tanstack-query lets you define query and mutation atoms. This bridges Jotai's atomic model with React Query's caching, deduplication, and background refetching.
import { atomWithQuery } from 'jotai-tanstack-query'
const postIdAtom = atom(1)
const postAtom = atomWithQuery((get) => ({
queryKey: ['post', get(postIdAtom)],
queryFn: async ({ queryKey }) => {
const [, id] = queryKey
const res = await fetch(`/api/posts/${id}`)
return res.json()
},
}))
function Post() {
const { data } = useAtomValue(postAtom)
return <article>{data.title}</article>
}
Similarly, atomWithMutation wraps mutations, and atomWithInfiniteQuery supports infinite scrolling patterns.
jotai-optics for Lens-Based Updates
The jotai-optics library uses Optics to focus on a specific part of a larger atom. This is useful when you have a deeply nested state object and want to expose a small slice as its own atom without manually writing getters and setters.
import { focusAtom } from 'jotai-optics'
const configAtom = atom({
ui: { sidebar: { open: false, width: 240 } },
api: { baseUrl: 'https://api.example.com' },
})
const sidebarOpenAtom = focusAtom(configAtom, (optic) =>
optic.prop('ui').prop('sidebar').prop('open')
)
function Sidebar() {
const [open, setOpen] = useAtom(sidebarOpenAtom)
return <button onClick={() => setOpen(!open)}>Toggle</button>
}
jotai-xstate for State Machines
For complex stateful logic, jotai-xstate integrates XState machines as atoms. This is ideal for multi-step flows, wizards, or any logic where transitions between states must be explicit and guarded.
import { atomWithMachine } from 'jotai-xstate'
import { createMachine } from 'xstate'
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: { on: { TOGGLE: 'active' } },
active: { on: { TOGGLE: 'inactive' } },
},
})
const toggleMachineAtom = atomWithMachine(() => toggleMachine)
function Toggle() {
const [state, send] = useAtom(toggleMachineAtom)
return (
<button onClick={() => send({ type: 'TOGGLE' })}>
{state.matches('active') ? 'On' : 'Off'}
</button>
)
}
jotai-cache for Memoized Async Atoms
When you have many async atoms that should cache their results, jotai-cache provides atomWithCache. It caches the resolved value and avoids refetching unless the cache is invalidated.
import { atomWithCache } from 'jotai-cache'
const weatherAtom = atomWithCache(async (get) => {
const city = get(cityAtom)
const res = await fetch(`/api/weather?city=${city}`)
return res.json()
})
Best Practices
Keep Atoms Small and Focused
Resist the temptation to put your entire application state into a single atom. That defeats the purpose of Jotai's fine-grained subscriptions. Instead, define small atoms for individual pieces of state and compose them with derived atoms. This keeps re-renders minimal and makes your state graph easier to reason about.
Co-locate Atoms with Features
Define atoms in the same files or folders as the features they belong to, rather than in a giant central store.ts file. This improves discoverability and keeps imports short. If multiple features share an atom, lift it to a common module.
Use Read-Only and Write-Only Hooks
Default to useAtomValue and useSetAtom instead of useAtom. This makes the component's intent explicit and prevents unnecessary re-renders when a component only needs to write to an atom.
Avoid Creating Atoms Inside Components
Creating an atom with atom() inside a component body creates a new atom on every render, which breaks the subscription model. Define atoms at module scope. If you need per-component state, use React's useState or pass an atom as a prop.
// Bad: new atom every render
function Bad() {
const valueAtom = atom(0)
const [value] = useAtom(valueAtom)
return <p>{value}</p>
}
// Good: atom defined outside
const valueAtom = atom(0)
function Good() {
const [value] = useAtom(valueAtom)
return <p>{value}</p>
}
Use Provider for Isolated State
By default, Jotai uses a global store. If you need isolated state per subtree (for example, in a multi-tenant dashboard or when rendering multiple instances of the same widget), wrap that subtree in a Provider with its own store.
import { Provider } from 'jotai'
function App() {
return (
<div>
<Provider><Widget instanceId="a" /></Provider>
<Provider><Widget instanceId="b" /></Provider>
</div>
)
}
Type Your Atoms Explicitly
Jotai is written in TypeScript and infers types well, but for atoms with complex shapes or async atoms, explicit typing prevents subtle bugs. Use generics on atom and on hooks when inference is ambiguous.
type User = { id: string; name: string; email: string }
const userAtom = atom<User | null>(null)
const usersAtom = atom<User[]>([])
Test with Vanilla Store
Jotai's jotai/vanilla package exposes a framework-agnostic store API. You can create a store, set atom values, and read derived atoms without rendering any components. This makes unit testing business logic straightforward.
import { createStore } from 'jotai/vanilla'
const store = createStore()
store.set(countAtom, 5)
console.log(store.get(doubleCountAtom)) // 10
Conclusion
Jotai's atomic model offers a refreshing alternative to traditional state management in React. By composing small, focused atoms into a reactive graph, you get fine-grained updates, minimal boilerplate, and a natural fit for both synchronous and asynchronous state. The official integration libraries โ Immer for immutable updates, TanStack Query for server state, Optics for lens-based slicing, XState for state machines, and others โ extend the core without bloating it, letting you adopt only what your application needs. By following patterns like atomFamily, splitAtom, and atomWithStorage, and by adhering to best practices around atom granularity, hook selection, and testing, you can build applications that scale gracefully from a single component to a complex, feature-rich product. Whether you are starting a new project or migrating away from a heavier solution, Jotai provides the primitives and ecosystem to manage state with confidence.