Concept
The beginner framing: where Zustand centers on one store object with selectors carving out slices, Jotai has no central store at all, state is built from small, independent atoms, each one an isolated unit of state that a component can read and write directly.
import { atom, useAtom } from "jotai";
const countAtom = atom(0);
const userAtom = atom({ name: "Ada" });
function Counter() {
const [count, setCount] = useAtom(countAtom); // reads AND writes this ONE atom
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}There's no selector function here the way Zustand needs one, a component simply reads the specific atom it cares about, and it's automatically subscribed to only that atom.
const countAtom = atom(0);const userAtom = atom({ name: 'Ada' });const doubledAtom = atom((get) => get(countAtom) * 2); // DERIVED
Three independent subscriptions: two to plain atoms, one to a DERIVED atom whose value is computed FROM countAtom.
Derived atoms: computed state that recomputes and re-notifies automatically
const doubledAtom = atom((get) => get(countAtom) * 2); // READ-ONLY derived atom
function Doubled() {
const doubled = useAtomValue(doubledAtom); // read-only hook, no setter needed
return <span>{doubled}</span>;
}Confirmed by running this exact atom graph: updating countAtom re-renders both a component reading countAtom directly and a component reading doubledAtom, the update propagates through the dependency graph, since doubledAtom's value is computed from countAtom. A component reading a completely unrelated atom (userAtom) is untouched by either change, confirmed via the same run.
Writable derived atoms: computing a read AND defining a custom write
const celsiusAtom = atom(0);
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9 / 5 + 32, // read: derive from celsius
(get, set, newFahrenheit) => { // write: translate back and set celsius
set(celsiusAtom, (newFahrenheit - 32) * 5 / 9);
}
);Confirmed by running this exact pair: writing 212 to fahrenheitAtom correctly updates celsiusAtom to 100, a two-argument atom(read, write) lets a derived atom be both computed and writable, with the write function translating the incoming value back into an update on the underlying atom(s) it derives from. This has no direct equivalent in Zustand's model, where derived values are typically just computed inline inside selectors rather than being independently writable entities.
The three core hooks
const [value, setValue] = useAtom(someAtom); // read + write, like useState
const value = useAtomValue(someAtom); // read-only, skips subscribing to the setter
const setValue = useSetAtom(someAtom); // write-only, a component that only DISPATCHES, never readsConfirmed all three exist in the current package. useAtomValue/useSetAtom being separate from the combined useAtom matters for a subtle but real optimization: a component that only ever calls the setter (like a "reset" button) doesn't need to re-render when the atom's value changes at all, and using useSetAtom specifically avoids subscribing it to value updates it never reads.
Try It
Predict the outcome before checking the solution.
const aAtom = atom(1);
const bAtom = atom(2);
const sumAtom = atom((get) => get(aAtom) + get(bAtom));
// ComponentSum reads sumAtom
// only aAtom is updated:
store.set(aAtom, 10);Does the component reading sumAtom re-render?
Solution
Yes. sumAtom depends on both aAtom and bAtom, confirmed by the dependency-graph mechanism demonstrated above, ANY atom a derived atom reads via get() becomes a real dependency, and changing any one of them (here, aAtom) recomputes sumAtom and notifies its subscribers. This is true even though bAtom itself didn't change, the derived atom's dependency list includes both, so either one changing is sufficient to trigger recomputation.
Implement It Yourself
Build a minimal atom system with derived-atom dependency tracking, to see the graph mechanism Jotai wraps:
function createAtom(initialValueOrRead) {
const isDerived = typeof initialValueOrRead === "function";
const atomObj = { listeners: new Set(), deps: new Set() };
if (!isDerived) {
atomObj.value = initialValueOrRead;
} else {
const get = (depAtom) => {
atomObj.deps.add(depAtom);
depAtom.listeners.add(() => recompute());
return depAtom.value;
};
const recompute = ()
This is the essential shape of the dependency graph, a derived atom's get() calls double as dependency registration, so the system knows exactly which base atoms to watch, without any manual selector or subscription list written by the developer.
Under the Hood
Jotai's dependency-graph model is architecturally distinct from Zustand's single-store-plus-selector model, both achieve granular re-rendering, but Zustand does it by comparing a selector's output against its previous result, while Jotai does it by tracking which atoms a component (or derived atom) actually reads. The two are worth holding as genuinely different mental models, not interchangeable syntax for the same idea. Recoil uses a closely related atom/selector model, enough so that this topic's core lesson carries over almost unchanged.
Common Mistakes
1. Using useAtom when only reading (or only writing) is needed
const [value] = useAtom(someAtom); // ❌ still subscribes to the SETTER machinery unnecessarilyuseAtomValue/useSetAtom exist specifically to avoid subscribing to the half of useAtom's behavior a component doesn't actually use, a minor but real, confirmed-available optimization.
2. Assuming a derived atom only depends on atoms it "obviously" reads
const resultAtom = atom((get) => {
if (get(flagAtom)) return get(aAtom);
return get(bAtom); // conditionally read
});Jotai's dependency tracking is based on which get() calls actually execute during a given computation, a conditionally-read atom is only a dependency on runs where that branch executes, which can produce subtly different re-render behavior than expecting all three atoms to always be dependencies.
3. Expecting a writable derived atom's write function to work without translating the value
const fahrenheitAtom = atom(
(get) => get(celsiusAtom) * 9/5 + 32,
(get, set, newValue) => set(celsiusAtom, newValue) // ❌ forgets to convert back to celsius
);The write function receives whatever value was passed to the atom's setter, it's the write function's job to translate that back into an update on the underlying atom(s); nothing does this automatically.
Best Practices
- Use
useAtomValue/useSetAtominstead ofuseAtomwhen a component only reads or only writes, to avoid unnecessary subscription overhead. - Keep derived atoms pure, their read function should only depend on other atoms via
get(), with no side effects, since it may run multiple times as dependencies change. - Reach for writable derived atoms when a piece of UI-facing state is naturally a transformation of another atom (like the Celsius/Fahrenheit example) rather than maintaining two separately-synced atoms by hand.
Performance Tips
- Dependency tracking means a derived atom only recomputes when an atom it actually reads changes, an atom it conditionally never reads (per the common mistake above) never triggers a recomputation, which can be a genuine, deliberate optimization for conditionally-expensive derived values.
- Since atoms have no central store to diff against, a Jotai app with many independent atoms doesn't pay any cost proportional to the store's total size when one atom updates, the update path is exactly the atom's own subscriber list plus whatever depends on it.
