Topic 15.5
Atoms, Signals and Other Models
In one line
Besides one big store, state can be split into tiny independent atoms (Jotai, Recoil), tracked automatically with signals or observables (Preact Signals, MobX), or modelled as a state machine (XState); each model trades React's simple re-render rule for a different kind of precision.
Think of it like this
Three ways to run a classroom. In the first, the teacher keeps one big register of everything (a store, like Redux or Zustand). In the second, every student has their own small notebook, and you look only at the notebooks you care about (atoms). In the third, a helper quietly watches who reads which notebook and taps exactly those people on the shoulder when it changes (signals). And separately, the school bell follows a strict timetable: it can't ring "lunch over" before "lunch started" (a state machine).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Atom
- A tiny independent piece of state that components subscribe to on its own.
- Derived atom
- An atom whose value is computed from other atoms and updates when they change.
- Signal
- A value that remembers who read it and tells exactly those readers when it changes.
- Automatic dependency tracking
- The library notices which values your code reads while it runs, so you never list dependencies.
- Fine-grained reactivity
- Updating only the exact piece of UI that depends on a changed value, not a whole component.
- Observable (MobX)
- An object MobX watches, so code that reads it can react when it changes.
- State machine
- A model listing every state something can be in and which events move it from one state to another.
- Guard
- A condition that must be true for a state machine transition to happen.
- Actor
- In XState, a running machine (or other process) that receives events and can send them to others.
Step by step
01Atoms: bottom-up state
Each atom has a value, a list of listeners, and get/set/subscribe: an external store of one value. Components read an atom with useSyncExternalStore(atom.subscribe, atom.get). Because each atom is its own store, nothing needs a selector.
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
const priceAtom = atom(250);
const qtyAtom = atom(1);
const totalAtom = atom((get) => get(priceAtom) * get(qtyAtom)); // derived
function Qty() {
const [qty, setQty] = useAtom(qtyAtom);
return <button onClick={() => setQty((q) => q + 1)}>Qty: {qty}</button>;
}
function Total() {
return <p>Total: ₹{useAtomValue(totalAtom)}</p>;
}
function Reset() {
const setQty = useSetAtom(qtyAtom); // writes only: never re-renders on change
return <button onClick={() => setQty(1)}>Reset</button>;
}02Signals: reading is subscribing
A signal's getter checks "which effect is running right now?" and adds it to its subscribers. When the value is set, it re-runs exactly those effects. No component re-runs at all in a signals framework; in React you bridge the signal with useSyncExternalStore.
03MobX: observable objects
A class becomes observable; mutations happen in actions; computed getters cache derived values; observer components re-render only when observables they read change.
import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";
class CartStore {
items = [];
constructor() { makeAutoObservable(this); } // fields observable, methods actions, getters computed
add(item) { this.items.push(item); } // plain mutation inside an action
get total() { return this.items.reduce((t, i) => t + i.price, 0); }
}
export const cart = new CartStore();
export const Total = observer(function Total() {
return <p>Total: ₹{cart.total}</p>; // re-renders only when total changes
});04State machines: events, states, transitions
List the states, and for each state the events it accepts. An event that isn't listed for the current state is ignored. In React, a transition table plus useReducer is a small machine; XState adds guards, side effects, timers, nesting and a visual editor.
import { assign, setup } from "xstate";
import { useMachine } from "@xstate/react";
const fetchMachine = setup({
guards: { canRetry: ({ context }) => context.retries < 3 },
actions: { countRetry: assign({ retries: ({ context }) => context.retries + 1 }) },
}).createMachine({
id: "fetch",
initial: "idle",
context: { retries: 0 },
states: {
idle: { on: { FETCH: "loading" } },
loading: { on: { RESOLVE: "success", REJECT: "failure" } },
success: { on: { FETCH: "loading" } },
failure: { on: { RETRY: { guard: "canRetry", target: "loading", actions: "countRetry" } } },
},
});
function Loader() {
const [snapshot, send] = useMachine(fetchMachine);
return snapshot.matches("loading")
? <p>Loading…</p>
: <button onClick={() => send({ type: "FETCH" })}>Load</button>;
}Try it yourself
- 1
Add a write-only component
In the atoms example, add
function Reset() { console.log(" render Reset"); return <button onClick={() => qtyAtom.set(1)}>Reset</button>; }to the root. Predict: does it render when you click Qty? It never reads an atom, so it never subscribes and never re-renders. This is Jotai'suseSetAtom. - 2
Read a signal outside an effect
In the signal example, add
console.log(first.value);at the top level, thenfirst.value = "Ravi";. Predict which effects run. Effect A runs (it readfirstwhile running); the top-level read subscribed nothing, because no effect was running. - 3
Add a guard
In the state machine, allow at most two retries: keep a
retriescount next to the state in the reducer ({ value, retries }) and ignoreRETRYwhenretries === 2. This is what XState'sguarddoes.
Code & diagrams
No selectors anywhere: each component subscribes to whole atoms, and atoms are small. Changing `qtyAtom` re-renders `Qty` and, through the derived `totalAtom`, `Total`. `ThemeName` renders only when the theme changes. Jotai works on the same idea, with automatic tracking for derived atoms and a store per `<Provider>`.
Preview
Console
Nothing logged yet.Expected console output
render Qty
render Total
render ThemeName
click Qty
render Qty
render Total
set theme
render ThemeNameNobody listed dependencies: each effect subscribed to exactly the signals it read while running. Click the button: effect B logs again, because the React handler sets the same signal. Real libraries (Preact Signals, SolidJS) add batching, computed values, cleanup and glitch-free ordering on top of this idea.
Preview
Console
Nothing logged yet.Expected console output
effect A: full name = Asha Rao
effect B: clicks = 0
-- set last
effect A: full name = Asha Kumar
-- set clicks
effect B: clicks = 1
-- set clicks to the same value`RETRY` while loading and `RESOLVE` after a failure are not in the table, so the state doesn't change (React still called `Loader` once for the ignored `RETRY` before seeing the state was the same, which is allowed). Greyed buttons show which events the current state accepts.
Preview
Console
Nothing logged yet.Expected console output
state: idle
send FETCH
state: loading
send RETRY
state: loading
send REJECT
state: failure
send RESOLVE
state: failure
send RETRY
state: loading
send RESOLVE
state: successBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Reading a signal during render without a bridge
In the signal example, write function Clicks() { return <button onClick={() => { clicks.value++; }}>Clicked {clicks.value} times</button>; } (no useSignal).
Break #2
Boolean flags instead of a machine
Model a request with isLoading, isError and isSuccess booleans, and set them in three different handlers.
Myth vs fact
Myth
Signals would make React faster, so React will switch to them.
Fact
The React team has chosen to keep the snapshot model (UI = f(state)) that concurrent rendering depends on, and to reduce wasted renders with the React Compiler instead.
Myth
Atoms are just a fancier useState.
Fact
Atoms live outside components, can be shared anywhere, derived from each other and read without prop drilling or selectors. They're closer to many tiny external stores.
Myth
State machines are only for games or hardware.
Fact
Any UI with steps and statuses is a state machine already: forms, checkouts, uploads, auth flows. Writing it down explicitly removes impossible states.
Myth
Recoil is the official React state library.
Fact
Recoil came from Meta, but it was never part of React, and it was archived in early 2025. Jotai is the actively maintained library with a similar atom model.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Atom libraries scale differently from stores: thousands of atoms are fine (each with its own subscribers), and Jotai's
atomFamily(injotai/utils) or aMapof atoms gives per-item state, e.g. one atom per row, so editing a row re-renders only that row with no selector work at all. - ▸
Signals frameworks avoid the virtual DOM diff for updates, but pay elsewhere: tracking costs memory, "glitches" (seeing half-updated derived values) need careful scheduling, and code must read values inside tracked scopes. React chose predictability and concurrency; the Compiler (Topic 16.5) closes much of the speed gap for typical apps.
- ▸
MobX can feel like magic until it doesn't: destructuring an observable in a parent (
const { name } = user) and passingnamedown means the child doesn't trackuser.name. Pass the observable object and dereference late, insideobservercomponents. - ▸
XState v5 treats everything as an actor: machines, promises and callbacks. A whole app's logic can run as a system of actors outside React, with components only sending events and reading snapshots via
useSelector(actorRef, selector)from@xstate/react, which again is a selector-based subscription.
Remember this
- 1
Atoms are tiny independent pieces of state. Instead of one store with selectors, you create many small units (
priceAtom,qtyAtom,themeAtom) and components subscribe to exactly the atoms they read, so a change re-renders only those readers, with no selectors to write. Derived atoms compute a value from other atoms (totalAtom = price × qty) and update when their sources change. State is built bottom-up from atoms, not cut top-down from one big object. The live example below builds a 15-line atom library onuseSyncExternalStoreto show there is no magic. - 2
Jotai is the leading atom library:
const countAtom = atom(0), thenconst [count, setCount] = useAtom(countAtom)in a component,useAtomValueto only read,useSetAtomto only write (that component doesn't re-render on changes). A derived atom isatom((get) => get(priceAtom) * get(qtyAtom)); Jotai tracks which atomsgettouched, so you don't list dependencies. Atoms are only definitions: values live in a store (a default one, or one per<Provider>), which makes per-request state in server rendering easy. Recoil, Meta's earlier atom library, introduced the idea to many React developers but was archived by Meta in early 2025 and is no longer maintained; don't start new projects with it, and plan a move (often to Jotai, whose API is close) if you use it. - 3
Signals are values that know who reads them. A signal holds a value; when code reads
signal.valueinside an effect or a computed value, the signal records that reader automatically (automatic dependency tracking). When the value changes, only those readers re-run. This is fine-grained reactivity: SolidJS, Preact Signals, Vue'sref, Angular signals and Svelte 5 runes all use it, and a TC39 proposal aims to add signals to JavaScript itself. Frameworks built on signals can update a single text node without re-running any component function. - 4
Why React doesn't use signals. React's model is UI = f(state): each render is a pure function call that sees one consistent snapshot of props and state. That model is what makes concurrent rendering (pausing and restarting renders, Topic 16.7), transitions, Suspense and server components work: React can render a version of the UI "off-screen" without changing what's shown. Signals are mutable cells read at any time, so different parts of a render could see different values (tearing). React's answer to "re-renders do too much work" is skipping work automatically with the React Compiler (Topic 16.5) instead of changing the model. Community adapters that put signals into React exist, but they reach into React's internals or need a build transform, so treat them with care. To use any signal-like source safely, subscribe through
useSyncExternalStore, as the live example does. - 5
MobX applies the same tracking idea to whole objects: you write a normal class or object, make it observable (
makeAutoObservable(this)), change it by plain mutation inside actions (this.items.push(item)), and derive values with computed getters. Wrapping a component inobserver(frommobx-react-lite) makes it track which observables it read during render and re-render only when those change. It feels like plain object-oriented code and gives precise updates; the price is mutable, "magic" data flow that is harder to trace than Redux's explicit actions. - 6
State machines model which states exist and which events may move between them. A fetch can be
idle,loading,successorfailure;RESOLVEis only allowed whileloading. Writing that as a table makes impossible states impossible: noisLoading && isErrorat the same time, no double submit. You already did a small version with a status variable (Topic 6.1) and a reducer (Topic 9.4). XState (version 5) is the full tool:setup({...}).createMachine({...})with states, events, guards (conditions on a transition), actions (side effects on transitions), context (extra data), nested and parallel states, timers, and actors (running machines that talk to each other).useMachine(machine)from@xstate/reactreturns[snapshot, send]. It also draws your machine as a diagram (Stately's visual editor). Use it for complex flows: checkout, multi-step wizards, media players, upload pipelines.
Explain it without notes
How does an atom-based library like Jotai differ from a single-store library like Zustand or Redux?
What are signals, and why doesn't React use them for state?
How does MobX decide which components to re-render?
What problem do state machines solve in UI code, and when would you reach for XState?
Practice
Using the hand-made atom from the live example, add a discountAtom (0 to 1) and make totalAtom apply it. Which components re-render when the discount changes?
Write a transition table for a traffic light (green → yellow → red → green on TIMER, and any state → flashing on FAULT, flashing → red on RESET) and a reducer that uses it.
Trade-offs
- ↔
Atoms and signals give precise updates with little code, but state is spread over many small definitions, which can be harder to see as a whole than one store in DevTools.
- ↔
State machines remove impossible states and document behaviour, but they take more upfront modelling; for a simple toggle or a single fetch, a status value is enough.
- ↔
MobX's mutable style is quick to write and very precise, but its implicit tracking is harder to debug and doesn't follow React's immutable-snapshot conventions, so teams mixing styles get confused.
Done when you can
I can explain atoms and derived atoms, and write a tiny atom on
useSyncExternalStore.I know Recoil is archived and Jotai is the maintained atom library.
I can explain signals, automatic tracking and why React keeps snapshots.
I can describe how MobX's
observerdecides what to re-render.I can model a flow as a state machine with a transition table.
I know when XState is worth adding.