skip to content

When one form field changes, how does Jotai decide what recomputes and re-renders, compared with a single Zustand store holding all the fields?

level: middleimportance: should knowfreq 40%

answer

  1. graph versus broadcast
  2. dependencies recorded by get
  3. only changed atoms notify listeners
  4. every subscriber re-runs its selector
  5. where the derived flag lives

basics

~20 s

Jotai follows the dependency graph recorded by get: writing one field atom recomputes only the mounted atoms that read it and notifies only atoms whose value changed. A single Zustand store notifies every subscriber, and each selector filters.

solid answer

~50 s

In Jotai each field is its own atom, and a derived `isValidAtom` records its dependencies by calling `get` on them. Writing `emailAtom` marks it changed; the store then recomputes, in dependency order, only the **mounted dependents** that read it, and calls listeners only for atoms whose value actually changed by `Object.is`. The password input is not involved at all, and the submit button re-renders only when the boolean flips. In a single Zustand store, the same keystroke calls `set`, which creates a new state object and notifies **every** subscriber of that store; each component re-runs its selector and re-renders only if its result changed. So Jotai narrows work by the graph, Zustand by selectors run after a broadcast. The trade-off is that Zustand keeps one snapshot of the whole form, while in Jotai a whole-form view needs a derived atom that reads every field.

code

ts · 15 lines
ts
import { atom } from 'jotai'

export const emailAtom = atom('')
export const passwordAtom = atom('')

// depends on exactly the atoms it reads with get
export const isValidAtom = atom(
  (get) => get(emailAtom).includes('@') && get(passwordAtom).length >= 8,
)

// whole-form payload for submit: a derived atom reading every field
export const signupPayloadAtom = atom((get) => ({
  email: get(emailAtom),
  password: get(passwordAtom),
}))

go deeper

for a junior

Know the two shapes: Jotai uses one atom per field with a derived atom for isValid, Zustand one store with a selector for isValid.

for a middle

Walk through one keystroke in each: Jotai recomputes mounted dependents and notifies only changed atoms; Zustand notifies all subscribers and selectors filter by Object.is.

for a senior

Judge the cost at scale: many subscribers and expensive derivations favour the atom graph, while a single serialisable snapshot favours the store.

for a principal

Frame the choice as a modelling decision for the team: bottom-up atoms per feature versus one store per domain, and what each means for persistence and debugging.

## Two ways to model the same form Take a sign-up form with `email`, `password` and a derived `isValid` flag that enables the submit button. The two libraries model it differently: - **Jotai (bottom-up):** three atoms. `emailAtom` and `passwordAtom` are primitive atoms; `isValidAtom = atom((get) => ...)` is a derived atom that reads both. - **Zustand (top-down):** one store created with `create`, holding `{ email, password, setEmail, setPassword }`; `isValid` is computed in a selector, or stored and kept in sync by the actions. The question is what happens, mechanically, when the user types one character into the email field. ## What Jotai does on a write Jotai's store keeps, per atom, its value, an epoch number that increments when the value changes, and its dependencies. The dependencies of a derived atom are exactly the atoms its read function passed to `get` during its **last** run; they are refreshed on every run, so conditional reads are tracked correctly. When `setEmail('a@b')` runs: 1. The store writes `emailAtom`. If the new value is `Object.is`-equal to the old one, nothing changes and nothing is notified. 2. Otherwise `emailAtom` is marked changed, and the store walks its **mounted dependents** — derived atoms that something is currently subscribed to — in dependency order. 3. Each dependent whose dependencies changed re-runs its read function. `isValidAtom` recomputes; if the boolean is the same as before, its epoch does not advance and it counts as unchanged. 4. Listeners are called only for atoms that changed. The email input re-renders; the submit button re-renders only if `isValid` flipped; the password input is never touched. A derived atom that nothing subscribes to is not recomputed eagerly; its read function runs the next time something reads it. ## What Zustand does on a write In Zustand the same keystroke calls an action that calls `set({ email })`: 1. `set` merges into a new state object and calls **every listener** of that store synchronously. 2. Each subscribed component re-runs its selector against the new state: the email input's `(s) => s.email`, the password input's `(s) => s.password`, the button's `(s) => isValidForm(s)`. 3. Each component re-renders only if its selector's result changed by `Object.is`. The result on screen is the same: the password input and, usually, the button do not re-render. The difference is the **work to find that out**: every selector in every subscribed component runs on every write, so selectors must be cheap and return stable values. ## Side by side | | Jotai | Zustand | |---|---|---| | Unit of state | one atom per field | one store object | | How dependents are found | graph recorded by `get` | every subscriber is notified | | Derived `isValid` | an atom, cached until its inputs change | a selector run per subscriber, or a stored field | | What is filtered by `Object.is` | the atom's value, before listeners run | each selector's result, after listeners run | | Whole-form snapshot | a derived atom that reads every field | `getState()` | | Writing several fields at once | a write-only atom calling `set` repeatedly | one `set` with several keys | ## Choosing between them for this form - With a handful of fields, both are fast; pick on ergonomics. - With many independently edited fields, many subscribers and expensive derived values, Jotai's graph avoids running unrelated work on every keystroke, and each derived atom is computed once rather than once per subscriber. - When you need one serialisable snapshot — submitting, persisting a draft, logging — Zustand's single object is simpler; in Jotai you add a derived atom that assembles the payload. - Code that must read or write the form from outside React works in both: Zustand's `useFormStore.getState()`, Jotai's `store.get(emailAtom)` on a store from `createStore` or `getDefaultStore`. ## Common misunderstandings - Jotai does not re-render every component that uses *any* atom; only listeners of changed atoms run. - Zustand does not re-render every subscriber on every `set`; it runs every subscriber's selector, which is cheaper but not free. - Jotai's derived atoms are not effects: they compute values when needed and never write elsewhere.

  • In Jotai, isValidAtom recomputes after a keystroke but stays true. Does the submit button re-render?
    No. After the read function runs, the store compares the new value with the old one using `Object.is`. An unchanged boolean does not advance the atom's epoch, so it is not treated as changed and its listeners, including the button's subscription, are not called.
  • How do you read the whole Jotai form at submit time without subscribing a component to every field?
    Read it inside a write function: a write-only atom such as `atom(null, (get) => submit(get(signupPayloadAtom)))` reads the payload with `get`, which does not subscribe. The submit button calls it through `useSetAtom`, so it does not re-render on keystrokes, and the read happens only when the user submits.

saying these in an interview costs you the question

  • Jotai re-renders every component that uses any atom whenever one atom changes.
  • Zustand re-renders every subscribed component on every set call.
  • Jotai derived atoms need a manual dependency array to know what to recompute.
  • A Jotai derived atom recomputes on every store write, even for unrelated atoms.
  • Zustand and Jotai must be combined to get a derived isValid flag.