Skip to main content
  1. Posts/

Jotai vs Zustand under the hood: broadcast selectors vs dependency graph

·7 mins

If you use React state libraries seriously, this is one of the best questions to understand deeply:

Why do Zustand and Jotai feel different under load, even when both are fast in simple demos?

The answer is not in their public API shape. The answer is in how updates flow internally.

In this post, we will walk through real internals from both repos and build a mental model of what code executes on each update.

Why this matters #

At small scale, both libraries feel great.

At medium and large scale, the update model starts to matter more than syntax:

  • How many listeners run per update?
  • Where does equality short-circuit work happen?
  • Is recomputation global-ish or graph-driven?
  • How much accidental rework can happen in hot paths?

Quick mental model first #

In simple terms:

  • Zustand: update store -> notify subscribers -> each subscriber computes selected slice -> equality decides if component re-renders.
  • Jotai: write atom -> invalidate dependents in graph -> topologically recompute only affected atoms -> notify listeners of changed mounted atoms.

That is the core architectural difference.

Zustand internals: event broadcast + selector/equality gate #

1. setState updates and broadcasts to all listeners #

In src/vanilla.ts, Zustand keeps a Set of listeners. On setState, if the new state is not Object.is equal to the old state, it updates state and calls all listeners.

Excerpt from src/vanilla.ts:

Real source code:

const setState: StoreApi<TState>['setState'] = (partial, replace) => {
  const nextState =
    typeof partial === 'function'
      ? (partial as (state: TState) => TState)(state)
      : partial
  if (!Object.is(nextState, state)) {
    const previousState = state
    state =
      (replace ?? (typeof nextState !== 'object' || nextState === null))
        ? (nextState as TState)
        : Object.assign({}, state, nextState)
    listeners.forEach((listener) => listener(state, previousState))
  }
}

const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
  listeners.add(listener)
  // Unsubscribe
  return () => listeners.delete(listener)
}

Why this is important:

  • Notification is broadcast-style.
  • All subscribers run for each accepted state update.
  • Fine-grained filtering happens later, at selector/equality level.

2. React hook path (useSyncExternalStore) #

In src/react.ts, Zustand binds React via useSyncExternalStore:

Real source code:

const slice = React.useSyncExternalStore(
  api.subscribe,
  React.useCallback(() => selector(api.getState()), [api, selector]),
  React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)

This means every subscribed component runs its selector when the subscription notifies React. Then React compares snapshots (default Object.is semantics).

3. Where equality customization appears #

If you want custom equality, Zustand exposes it through other paths:

  • src/traditional.ts -> useSyncExternalStoreWithSelector(..., equalityFn)
  • src/middleware/subscribeWithSelector.ts -> defaults to Object.is, can receive equalityFn
  • src/react/shallow.ts -> useShallow memoizes selector output using shallow comparison

Real source code:

Excerpt from subscribeWithSelector.ts:

api.subscribe = ((selector: any, optListener: any, options: any) => {
  let listener: Listener = selector // if no selector
  if (optListener) {
    const equalityFn = options?.equalityFn || Object.is
    let currentSlice = selector(api.getState())
    listener = (state) => {
      const nextSlice = selector(state)
      if (!equalityFn(currentSlice, nextSlice)) {
        const previousSlice = currentSlice
        optListener((currentSlice = nextSlice), previousSlice)
      }
    }
  }
  return origSubscribe(listener)
}) as any

So your statement is correct in practice: updates emit, selector listeners are called, but many can short-circuit through Object.is or shallow/custom comparisons.

Jotai internals: dependency graph + invalidation + recomputation #

Jotai does not start from one store object + one selector pipeline. It starts from atom relationships.

1. Atom state tracks dependencies, epoch, and pending dependents #

In src/vanilla/internals.ts, each AtomState and Mounted object tracks dependency edges and listeners:

Real source code:

type AtomState = {
  /**
   * Map of atoms that the atom depends on.
   * The map value is the epoch number of the dependency.
   */
  readonly d: Map<AnyAtom, EpochNumber>
  /** Set of atoms with pending promise that depend on the atom. */
  readonly p: Set<AnyAtom>
  /** The epoch number of the atom. */
  n: EpochNumber
  v?: Value
  e?: AnyError
}

type Mounted = {
  /** Set of listeners to notify when the atom value changes. */
  readonly l: Set<() => void>
  /** Set of mounted atoms that the atom depends on. */
  readonly d: Set<AnyAtom>
  /** Set of mounted atoms that depends on the atom. */
  readonly t: Set<AnyAtom>
  u?: () => void
}

This is the basis for dependency-graph execution.

2. Read path uses cache if deps epochs are unchanged #

readAtomState checks if an atom can reuse cache:

  • if mounted and not invalidated -> return cached state
  • else compare dependency epochs
  • if deps unchanged -> return cached state
  • otherwise recompute and update dependency links

Real source code:

Excerpt from BUILDING_BLOCK_readAtomState:

if (isAtomStateInitialized(atomState)) {
  // If the atom is mounted, we can use cached atom state.
  // We can't use the cache if the atom is invalidated.
  if (mountedMap.has(atom) && invalidatedAtoms.get(atom) !== atomState.n) {
    return atomState
  }
  // Otherwise, check if the dependencies have changed.
  let hasChangedDeps = false
  for (const [a, n] of atomState.d) {
    if (readAtomState(store, a).n !== n) {
      hasChangedDeps = true
      break
    }
  }
  if (!hasChangedDeps) {
    return atomState
  }
}

3. Write path invalidates dependents in graph #

On write, Jotai marks the atom changed, then walks dependents and invalidates transitively.

Excerpt from invalidateDependents + writeAtomState:

Real source code:

const stack: AnyAtom[] = [atom]
while (stack.length) {
  const a = stack.pop()!
  const aState = ensureAtomState(store, a)
  for (const d of getMountedOrPendingDependents(a, aState, mountedMap)) {
    const dState = ensureAtomState(store, d)
    if (invalidatedAtoms.get(d) !== dState.n) {
      invalidatedAtoms.set(d, dState.n)
      stack.push(d)
    }
  }
}

// inside writeAtomState setter branch (a === atom):
if (prevEpochNumber !== aState.n) {
  changedAtoms.add(a)
  invalidateDependents(store, a)
  storeHooks.c?.(a)
}

This is graph invalidation, not selector fan-out.

4. Recompute step is topologically ordered #

Jotai then recomputes invalidated atoms using a topological traversal over the affected graph. This is explicitly described in internals comments and code.

Real source code:

Excerpt from recomputeInvalidatedAtoms:

// Step 1: traverse the dependency graph to build the topologically sorted atom list
const topSortedReversed: [atom: AnyAtom, atomState: AtomState][] = []
const stack: AnyAtom[] = Array.from(changedAtoms)
while (stack.length) {
  const a = stack[stack.length - 1]!
  const aState = ensureAtomState(store, a)
  // ...
  for (const d of getMountedOrPendingDependents(a, aState, mountedMap)) {
    if (!visiting.has(d)) stack.push(d)
  }
}

// Step 2: use the topSortedReversed list to recompute affected atoms
for (let i = topSortedReversed.length - 1; i >= 0; --i) {
  const [a, aState] = topSortedReversed[i]!
  let hasChangedDeps = false
  for (const dep of aState.d.keys()) {
    if (dep !== a && changedAtoms.has(dep)) {
      hasChangedDeps = true
      break
    }
  }
  if (hasChangedDeps) {
    readAtomState(store, a)
    mountDependencies(store, a)
  }
  invalidatedAtoms.delete(a)
}

That is why Jotai feels like graph propagation instead of selector fan-out.

Side-by-side: what runs on each update #

Zustand write:
1. setState
2. listeners.forEach(...)
3. each subscriber selector runs
4. Object.is / shallow / custom equality gates re-render

Jotai write:

1. write atom
2. mark changed atom(s)
3. invalidate dependent atoms in graph
4. topological recompute on affected atoms
5. notify mounted listeners for changed atoms

Trade-offs you should care about #

Zustand strengths #

  • Very simple mental model.
  • Excellent ergonomics for store-style apps.
  • Easy to optimize with selector discipline.

Costs:

  • Broadcast subscription model means selector fan-out can grow with app size.
  • Bad selector patterns can leak work into hot updates.

Jotai strengths #

  • Natural fine-grained invalidation through atom graph.
  • Dependency-based recomputation can reduce unrelated work.
  • Strong fit for highly compositional state.

Costs:

  • More conceptual machinery (atoms, derived atoms, dependency lifecycles).
  • Debugging graph interactions may be harder for teams new to the model.

Practical recommendation #

If your team thinks in “single store + selectors”, Zustand is usually the fastest path to productivity.

If your team thinks in “state graph + derived units”, Jotai can scale update precision better when complexity grows.

The right decision is less about benchmarks and more about update topology in your real app.

Conclusions #

Zustand and Jotai are both excellent, but they optimize different execution models.

  • Zustand optimizes for store simplicity and selector-level gating.
  • Jotai optimizes for dependency-graph precision.

Once you see the low-level flow, the trade-offs stop being philosophical and become architectural.