vibekōdo
Back to blog

Understanding Jotai Store Subscriptions

vibekōdo3 min read

TL;DR

  • store.set(atom, value) is synchronous. It completes only after Jotai has written the value, recomputed all invalidated atoms and synchronously invoked every relevant subscriber.
  • A subscriber fires only when Object.is(prev, next) is false.
  • Derived atoms re-emit only when their computed value differs, not merely because their source atom changed.
  • If your subscriber does expensive work, you are the bottleneck—kick that work to a micro-task or worker if the UI must stay fluid.

1. A 60-second refresher on Jotai internals

flowchart TD
    A[store.set] --> B(writeAtomState)
    B --> C(recomputeInvalidatedAtoms)
    C --> D(flushCallbacks)
    D -->|iterates| E[subscribers]

The hard truth: no await anywhere. Every step runs inline.

If a subscriber's callback is slow, you feel it right inside your state write.

2. Equality check = Object.is

Jotai doesn't do deep compares, JSON stringification, etc. It's exactly:

const shouldNotify = !Object.is(oldValue, newValue);

Implications:

Type Example that fires Example that doesn't fire
string 'a' -> 'b' 'a' -> 'a'
number 1 -> 2 2 -> 2
object reference {} -> {} (new ref) sameRef -> sameRef
NaN 1 -> NaN NaN -> NaN

3. Derived atoms – nothing mystical

Take a base atom and a derived atom:

const countAtom = atom(0);
const doubleAtom = atom((get) => get(countAtom) * 2);
Scenario double value Fires?
set(countAtom, 1) 2
set(countAtom, 1) 2 ❌ (same)
set(countAtom, 2) 4

The derived atom recomputes every time countAtom changes, but it only notifies when the result actually differs.

Property selectors

const objAtom = atom({ foo: 1, bar: 1 });
const fooAtom = atom((get) => get(objAtom).foo);
Write objAtom fires fooAtom fires
{ foo: 1, bar: 2 } (bar changed) ❌ (foo same)
{ foo: 2, bar: 2 } (foo changed)

📍 Mutable update trap

const ref = store.get(objAtom);
ref.foo = 99;
store.set(objAtom, ref); // same reference → no fire at all

Always use immutable updates when working with reference types.

4. store.set and long-running subscribers

store.set won't yield control until all subscribers finish their synchronous work.

store.sub(atom, () => {
  heavyCPU(); // blocks!
  queueMicrotask(lightIO); // executes after store.set returns
});

If you must run heavy logic:

store.sub(atom, () => {
  setTimeout(heavyCPU, 0); // completely detach
});

or offload to a worker, or process inside React with useEffect.

5. Takeaways

  1. Synchronous mindset – A Jotai write is like a Redux dispatch; treat it as blocking.
  2. Immutable updates – they're the key for selective re-rendering and derived-atom hygiene.
  3. Measure & offload – heavy subscribers will tank performance; use async boundaries wisely.
  4. Backed by tests – automated specs help you trust these mechanics (and future-proof against library updates).

Happy atom-crafting! 🧪