A Jotai form component creates its field atoms inside the render body and then loops or loses every typed value; why, and how should per-field atoms be created instead?
answer
- what the store uses as a key
- a new object every render
- module scope or memoised
- a cache from parameter to atom
- caches that never forget
basics
~20 sJotai identifies atoms by object identity, so an atom created during render is a brand-new key each time: its value starts over and useAtom can loop. Define atoms at module scope, memoise them with useMemo, or use atomFamily from jotai-family in v3.
solid answer
~40 sA Jotai atom is a config object, and the store keys every value by that object's identity. `useAtom(atom(''))` inside a component therefore asks for a new, never-seen atom on every render: it is initialised afresh, the typed value is lost, and because the hook subscribes to a different atom each time, the docs warn it can cause an infinite loop. The fix is a stable reference: define atoms at module scope, or wrap creation in `useMemo`/`useRef` when it depends on props. For a dynamic set of fields keyed by name, use `atomFamily`, which caches one atom per parameter; in Jotai 3 it is no longer in `jotai/utils` and is imported from the `jotai-family` package. The cache is a map that never forgets on its own, so call `family.remove(param)` or `setShouldRemove` when fields go away.
code
tsx · 22 linesimport { useMemo } from 'react'
import { atom, useAtom } from 'jotai'
import { atomFamily } from 'jotai-family'
// one atom per field name, cached by the family
export const fieldAtomFamily = atomFamily((_name: string) => atom(''))
export function Field({ name }: { name: string }) {
const [value, setValue] = useAtom(fieldAtomFamily(name))
return <input name={name} value={value} onChange={(e) => setValue(e.target.value)} />
}
export function PrefilledField({ initial }: { initial: string }) {
// memoised: a new atom only when `initial` changes
const fieldAtom = useMemo(() => atom(initial), [initial])
const [value, setValue] = useAtom(fieldAtom)
return <input value={value} onChange={(e) => setValue(e.target.value)} />
}
export function removeField(name: string) {
fieldAtomFamily.remove(name) // evict the cached atom
}go deeper
Define atoms outside components by default; if an atom must depend on props, create it inside useMemo so it keeps one identity.
Explain that the store keys state by the atom object, so a new atom per render means a fresh initial value and a changing subscription.
Diagnose resets, loops and memory growth from atom identity, and manage atomFamily caches with remove or setShouldRemove in long-lived sessions.
Set conventions for where atoms are declared and how dynamic atoms are owned and evicted, and plan the jotai-family import change as part of a v3 upgrade.
## Atoms are identified by reference In Jotai, `atom(...)` returns a plain config object. The store does not look at the atom's initial value, its read function or any label to find its state; it uses the **object itself as the key**, in a `WeakMap`. Two calls to `atom('')` produce two different atoms, even though they look identical. That identity rule is what makes module-level atoms work: every component that imports `emailAtom` passes the same object, so they share one value per store. It is also what breaks atoms created carelessly inside components. ## What goes wrong when atoms are created in render Consider a form component that builds its field atoms in its body: ```tsx function Field({ name }: { name: string }) { const [value, setValue] = useAtom(atom('')) // new atom on every render return <input value={value} onChange={(e) => setValue(e.target.value)} /> } ``` Following one keystroke: 1. `setValue('a')` writes `'a'` into the atom created on the previous render. 2. The component re-renders, and `atom('')` creates a **new** atom that the store has never seen. 3. The store initialises the new atom with `''`, so the input shows an empty value again. 4. The hook now subscribes to a different atom than last time; the Jotai docs warn that this pattern can cause an infinite loop. The same happens to derived atoms defined inline, for example `useAtomValue(atom((get) => get(emailAtom).trim()))`: every render builds a new derived atom and recomputes it from scratch. ## Stable ways to create atoms - **Module scope** — the default. Field atoms that exist for every instance of the form live next to the component. - **`useMemo` or `useRef`** — when the atom depends on props, memoise it: `const fieldAtom = useMemo(() => atom(initial), [initial])`. The docs suggest `useMemo` when in doubt. Note that a new dependency value deliberately creates a new atom. - **Atoms stored in state or in other atoms** — an atom config is a value like any other, so a list of field atoms can itself live in an atom. - **`atomFamily`** — a function from a parameter to an atom, with a cache so the same parameter returns the same atom. ## `atomFamily` and its move in Jotai 3 For a dynamic form whose fields are known only at runtime, `atomFamily(initializeAtom, areEqual?)` is the usual tool: ```ts import { atom } from 'jotai' import { atomFamily } from 'jotai-family' export const fieldAtomFamily = atomFamily((name: string) => atom('')) // fieldAtomFamily('email') returns the same atom on every call ``` | Jotai version | Import | |---|---| | 2.x | `import { atomFamily } from 'jotai/utils'` | | 3.0 | `import { atomFamily } from 'jotai-family'` (separate package, same API) | Two details decide whether it behaves: - **Parameter equality.** `areEqual` defaults to `Object.is`. Passing an object parameter such as `{ form: 'signup', field: 'email' }` built inline creates a new cache entry, and a new atom, every call; pass a primitive key or supply a deep-equality function. - **Memory.** Internally the family is a map from parameter to atom config. Entries are never evicted on their own, so a form that adds and removes fields over a long session leaks atoms. Call `fieldAtomFamily.remove(name)` when a field is removed, or register `setShouldRemove((createdAt, param) => ...)` to evict by age or rule. ## Where the value goes when the atom does Because the store keys values by atom object in a `WeakMap`, identity also governs cleanup. A module-level atom stays reachable for the life of the page, so its value can stay in the store. An atom memoised inside a component becomes unreachable when that component unmounts and nothing else holds it, and its entry can then be garbage-collected. An `atomFamily` breaks that chain on purpose: its own cache holds every atom it created, which is exactly why it needs `remove` or `setShouldRemove`. ## A diagnostic checklist - An input resets to its initial value after every keystroke: look for `atom(` inside a component body or inside a hook without memoisation. - An infinite render loop appears after adding a derived atom: check that the derived atom is defined outside render or memoised. - Memory grows with the number of distinct records the user has opened: check `atomFamily` usage for missing `remove` calls or object parameters without `areEqual`. - After upgrading to Jotai 3, `atomFamily` is no longer exported from `jotai/utils`: install `jotai-family` and change the import.
- Why does atomFamily((p: { id: string }) => atom(0)) create a new atom on every call with an inline object?The family compares parameters with `Object.is` by default, and an inline `{ id }` object is a new reference each call, so it never matches a cached entry. Pass a primitive key such as the id string, or give `atomFamily` a deep-equality function as its second argument.
- If a Jotai atom created at module scope is never read again, does its value stay in memory?The module keeps the config reachable, so the store can keep its value while any code still references the atom. Stores key values by atom in a `WeakMap`, so an atom that becomes unreachable, such as a memoised one from an unmounted component, and is no longer mounted can be garbage-collected with its value.
saying these in an interview costs you the question
- Two atoms created with atom('') share a value because their initial values match.
- Creating atoms inside a component is fine because Jotai deduplicates them by label.
- In Jotai 3 atomFamily is still imported from jotai/utils.
- atomFamily frees atoms automatically when no component uses that parameter.
- Object parameters to atomFamily are compared deeply by default.