Build state from small independent atoms that compose, so components re-render only for the exact values they read.

Bishal Biswas
Self Learner
A different shape
Redux and Zustand both keep one big object and slice into it with selectors.
Jotai works the other way round. State is built from many atoms, each holding one small value, and larger values are composed from them.
npm install jotai
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The API deliberately mirrors useState. The difference is that the atom lives outside any component, so anything can read it.
Reading and writing separately
import { useAtomValue, useSetAtom } from 'jotai';
function Display() {
const count = useAtomValue(countAtom);
return <p>{count}</p>;
}
function IncrementButton() {
const setCount = useSetAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>+</button>;
}
useSetAtom does not subscribe. IncrementButton never re-renders when the count changes, because it only writes. That is a genuine advantage over a single store with selectors.
Derived atoms
This is where the model pays off.
const itemsAtom = atom([]);
const filterAtom = atom('all');
const visibleItemsAtom = atom((get) => {
const items = get(itemsAtom);
const filter = get(filterAtom);
if (filter === 'done') return items.filter((i) => i.done);
if (filter === 'active') return items.filter((i) => !i.done);
return items;
});
const remainingCountAtom = atom((get) =>
get(itemsAtom).filter((i) => !i.done).length
);
An atom taking a get function is read only and computed. It recalculates only when the atoms it reads change, and components using it re-render only then.
There is no dependency array. Jotai tracks what you read.
function ItemList() {
const items = useAtomValue(visibleItemsAtom);
return <ul>{items.map((i) => <li key={i.id}>{i.text}</li>)}</ul>;
}
function Counter() {
const remaining = useAtomValue(remainingCountAtom);
return <p>{remaining} remaining</p>;
}
Changing the filter re-renders ItemList but not Counter, because remainingCountAtom does not read the filter. That precision is automatic.
Writable derived atoms
const celsiusAtom = atom(0);
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9 / 5 + 32,
(get, set, newFahrenheit) => {
set(celsiusAtom, (newFahrenheit - 32) * 5 / 9);
}
);
function TemperatureInput() {
const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom);
return (
<input
type="number"
value={fahrenheit}
onChange={(e) => setFahrenheit(Number(e.target.value))}
/>
);
}
Two functions: how to read, and what to do when written. Both inputs stay in sync with one source of truth.
Action atoms
An atom with null as its read value becomes a pure action.
const addItemAtom = atom(null, (get, set, text) => {
const items = get(itemsAtom);
set(itemsAtom, [...items, { id: crypto.randomUUID(), text, done: false }]);
});
function AddButton() {
const addItem = useSetAtom(addItemAtom);
return <button onClick={() => addItem('New item')}>Add</button>;
}
The component writing it never re-renders, since there is nothing to read.
Async atoms
const userIdAtom = atom(1);
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`/api/users/${id}`);
return response.json();
});
function UserName() {
const user = useAtomValue(userAtom);
return <h2>{user.name}</h2>;
}
function Page() {
return (
<Suspense fallback={<Spinner />}>
<UserName />
</Suspense>
);
}
An async atom suspends, so Suspense handles the loading state. Change userIdAtom and it refetches automatically.
Note this has no cache invalidation, retries or background refetching. For serious server data, TanStack Query is still the answer.
Scoping with Provider
By default atoms are global. A Provider gives a subtree its own independent values.
import { Provider } from 'jotai';
function App() {
return (
<div>
<Provider>
<Editor />
</Provider>
<Provider>
<Editor />
</Provider>
</div>
);
}
Two editors, two completely separate sets of atom values, from the same atom definitions. Useful for multi instance widgets and for isolating tests.
Utilities
import { atomWithStorage } from 'jotai/utils';
const themeAtom = atomWithStorage('theme', 'light');
import { splitAtom } from 'jotai/utils';
const itemAtomsAtom = splitAtom(itemsAtom);
splitAtom turns a list atom into an atom of atoms, so each row subscribes only to its own item. Editing one row re-renders one row, which matters in long lists.
Jotai or the others?
Jotai when state is naturally lots of small independent values that derive from each other. Form fields, editor settings, filters and computed views fit well. The re-render precision is excellent and comes for free.
Zustand when you prefer one store with actions, and want the simplest possible model.
Redux Toolkit when you need DevTools time travel or a strict action log.
The atomic model takes a little adjustment if you are used to one big store, but derived state is much nicer than writing selectors.
Quick recap
- State is many small atoms rather than one object.
useAtommirrorsuseState, withuseAtomValueanduseSetAtomsplitting it.- A write only component never re-renders.
- Derived atoms take
getand track dependencies automatically. - A second function makes a derived atom writable.
- Async atoms suspend, so Suspense handles loading.
Providerscopes atoms to a subtree.splitAtomgives per row subscriptions in long lists.
import { atom, useAtom, useAtomValue, useSetAtom, Provider } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Atoms are small independent values that live outside components
const itemsAtom = atom([]);
const filterAtom = atom('all');
const themeAtom = atomWithStorage('theme', 'light');
// DERIVED atoms take get, and Jotai tracks the dependencies itself.
// No dependency array to keep in sync.
const visibleItemsAtom = atom((get) => {
const items = get(itemsAtom);
const filter = get(filterAtom);
if (filter === 'done') return items.filter((i) => i.done);
if (filter === 'active') return items.filter((i) => !i.done);
return items;
});
const remainingCountAtom = atom((get) =>
get(itemsAtom).filter((i) => !i.done).length
);
// An ACTION atom: null to read, a function to write
const addItemAtom = atom(null, (get, set, text) => {
set(itemsAtom, [
...get(itemsAtom),
{ id: crypto.randomUUID(), text, done: false }
]);
});
// A WRITABLE derived atom keeps two views of one value in sync
const celsiusAtom = atom(0);
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9 / 5 + 32,
(get, set, next) => set(celsiusAtom, (next - 32) * 5 / 9)
);
function ItemList() {
const items = useAtomValue(visibleItemsAtom);
return <ul>{items.map((i) => <li key={i.id}>{i.text}</li>)}</ul>;
}
function RemainingCount() {
// Changing the FILTER re-renders ItemList but NOT this, because
// remainingCountAtom never reads filterAtom. That precision is free.
const remaining = useAtomValue(remainingCountAtom);
return <p>{remaining} remaining</p>;
}
function AddButton() {
// useSetAtom does NOT subscribe, so this never re-renders
const addItem = useSetAtom(addItemAtom);
return <button onClick={() => addItem('New item')}>Add</button>;
}
// An ASYNC atom suspends, so Suspense handles the loading state
const userIdAtom = atom(1);
const userAtom = atom(async (get) => {
const response = await fetch('/api/users/' + get(userIdAtom));
return response.json();
});
export default function App() {
return (
<div>
<ItemList />
<RemainingCount />
<AddButton />
{/* A Provider scopes atoms, so these two editors have completely
separate values from the same atom definitions */}
<Provider><Editor /></Provider>
<Provider><Editor /></Provider>
</div>
);
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready