MobX takes the opposite bet from every other library in this series: instead of making you select state, it observes what your components actually read and re-renders exactly those. When the wiring is right it's the least ceremony in React. But the wiring is invisible β and the failure mode of invisible wiring is that when you break it, nothing tells you. Two of the traps below freeze your UI with a clean console and a store full of correct data.
Everything here was reproduced and measured in a real Chrome on mobx 7.0.4 + mobx-react-lite 5.0.3 + React 19.3 (Vite, client-side) with an instrumented window.fetch and render counters before publishing. The numbers β a UI stuck at "idle" over a store holding all 20 records, 111 fetches in 5 seconds with zero warnings, a page-2 header on page-1 rows β are observed counts, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CI β anywhere. When you want your own schema it's one curl or one click β see Β§15.
A class store with makeAutoObservable, the fetch in a flow (generator β every yield resumes inside the action), an observer component:
class ProductStore {
items = []; status = 'idle'
constructor() { makeAutoObservable(this) }
load = flow(function* () {
this.status = 'loading'
const r = yield fetch(`${API}/products`)
this.items = yield r.json()
this.status = 'ok'
})
}
const store = new ProductStore()
const List = observer(() => {
useEffect(() => { store.load() }, [])
if (store.status !== 'ok') return <div>loadingβ¦</div>
return <ul>{store.items.map(p => <li key={p.id}>{p.name}</li>)}</ul>
})
Measured: 1 fetch, 3 renders (idle β loading β ok), 20 rows (the demo API's default page size; X-Total-Count: 30 tells you the rest), clean console. Everything below breaks this baseline in a different way.
The classic "my MobX component is not updating". Same store, same effect, one wrapper missing:
function List() { // β plain component, no observer()
useEffect(() => { store.load() }, [])
return <ul>{store.items.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
Measured: the fetch fires, the store fills β inspecting it shows items.length === 20, status === 'ok' β and the UI renders exactly once and never again: status frozen at "idle", 0 rows, forever. Zero warnings, zero errors. Without observer() there is no subscription, so no mutation can ever reach the component. This is MobX's sharpest edge precisely because everything else is automatic: the one manual step is the one you forget, and the console won't help you.
Write the load as an ordinary async method and it looks action-wrapped β makeAutoObservable did wrap it. But the wrapper only covers the code up to the first await; the continuation runs outside:
async load() {
const r = await fetch(`${API}/products?limit=5`)
this.items = await r.json() // β this line is no longer inside the action
}
Measured: it works (5 rows render) β but the console warns, verbatim:
[MobX] Since strict-mode is enabled, changing (observed) observable values
without using an action is not allowed. Tried to modify: NaiveStore@3.items
That's MobX 6+'s default enforceActions: "observed" doing its job. Unlike the two silent traps on either side of this section, this one at least speaks up β don't silence it with configure({ enforceActions: "never" }), because it's the only tripwire you have.
// fix 1: wrap the post-await writes
const items = await r.json()
runInAction(() => { this.items = items })
// fix 2: use a flow β every yield resumes inside the action
load = flow(function* () {
const r = yield fetch(`${API}/products?limit=5`)
this.items = yield r.json()
})
Measured: both give 1 fetch, 2 renders, zero warnings. flow is the nicer default for anything with multiple awaits β no nesting, and it composes with cancellation (flowResult + .cancel()).
Destructuring feels idiomatic and is the second silent freeze:
const { items } = store // β captured once, outside any observer
const List = observer(() => (
<ul>{items.map(p => <li key={p.id}>{p.name}</li>)}</ul>
))
Measured: fetch fires, store fills to 5 records β UI stuck at 0 rows, 1 render, zero warnings. The destructure captured a reference to the original array; load() replaced this.items with a new one, and the component is still watching the corpse. Dereference inside the render instead (store.items.map(β¦)) β measured: 5 rows, works. Rule: observer tracks property reads during render; anything you dereference early β module scope, props destructuring, intermediate variables outside render β is invisible to it.
Delete the useEffect and "just call it":
const List = observer(() => {
store.load() // β in the render body
return <div>{store.items.length} items</div>
})
Measured: 43 fetches in the first 2 seconds, 111 by 5 seconds, renders tracking fetches 1:1, and it never stops. The console: zero warnings, zero errors β the quietest storm in this series (zustand's version at least warns once; jotai's is equally mute). The loop: render β load() β response replaces items β observer notified β re-render β load() β β¦ Every lap is a real network request against whatever API you pointed it at. Rehearse this against a rate-capped mock instead of your production backend.
The baseline from Β§2 wrapped in <StrictMode> (i.e. every fresh Vite app in dev): 2 fetches β React double-invokes the mount effect on purpose. Because MobX state lives outside React, the fix is store state, not an effect-cleanup dance:
load() {
if (this.inflight) return this.inflight // β guard lives in the store
this.inflight = (async () => { /* fetch + runInAction commit */ })()
return this.inflight
}
Measured under StrictMode: exactly 1 fetch, list renders normally.
A pager action written the natural way β set the page eagerly, fill the items when the response lands:
async go(p) {
this.page = p // header updates now (pre-await = in action)
const r = await fetch(`${API}/products?_page=${p}&_limit=5`)
const items = await r.json()
runInAction(() => { this.items = items }) // whoever resolves LAST wins
}
To reproduce the race deterministically, the mock decides who's slow β page 1 answers in 800 ms, page 2 in 100 ms (mock_delay is per-request):
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=1&_limit=3&mock_delay=800"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5&mock_delay=100"
Click page 1, then page 2 150 ms later. Measured: 2 fetches, 0 aborts, header reads "page 2" β and the list shows page 1's three rows, starting at id 1. Page 2's response arrived first and was silently overwritten when the stale page-1 response resolved. No warning; the store recorded history out of order, and the eager header makes the lie visible.
class Pager {
page = 0; items = []; ctrl = null
constructor() { makeAutoObservable(this, { ctrl: false }) } // don't observe the controller
async go(p) {
this.page = p
this.ctrl?.abort() // cancel the previous flight
const ctrl = new AbortController()
this.ctrl = ctrl
try {
const r = await fetch(`${API}/products?_page=${p}&_limit=5`, { signal: ctrl.signal })
const items = await r.json()
runInAction(() => { this.items = items })
} catch (e) { if (e.name !== 'AbortError') throw e }
}
}
Same click sequence, measured: exactly 1 abort, header "page 2", rows are page 2's (first id 6). Note the { ctrl: false } annotation β an AbortController has no business being observable, and excluding it costs one argument. (MobX's own flow + flowResult(β¦).cancel() is the built-in alternative if your whole pipeline is flows.)
No res.ok check β the JSON parses fine, so nothing throws where you'd expect:
const r = await fetch(`${API}/products?mock_status=500`)
const items = await r.json() // 500 body parses fine β it's an object
runInAction(() => { this.items = items })
Measured: items becomes the error object, the observer throws Uncaught TypeError: items.map is not a function, and without an error boundary the page goes blank (React's console note even says so: "Consider adding an error boundary to your tree"). With a boundary, the same throw renders a recoverable error UI β and the store still holds the poisoned items for the next subscriber, so keep an error field in the store and check res.ok. Drill every branch against real statuses:
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500" # any code: 401, 403, 429β¦
"Retry on 5xx" code paths usually reach production untested because you can't make prod fail twice on demand. mock_seq serves a deterministic status script β first request 503, second 503, third the real data:
const r = await fetch(`${API}/products?limit=3&mock_seq=503,503,200&mock_seq_key=run1`)
Measured with a plain for-loop retry in the store action: exactly 3 fetches, then 3 rows render and status flips to ok. Each response carries x-mockbird-seq: 1/3 β¦ 3/3 so every attempt is assertable; the sequence sticks on its last entry per key, so use a fresh mock_seq_key (or mock_seq_reset=1) per test run.
A worry we can retire with a number. This action commits its result in three separate runInAction blocks:
runInAction(() => { this.items = items })
runInAction(() => { this.count = items.length })
runInAction(() => { this.at = Date.now() })
Measured: a component reading all three fields renders twice total β mount + one update, identical render counts to the single-runInAction control. React 18+ batches the whole microtask continuation, so three commits collapse into one paint. Write clear actions; don't contort them into one mega-block for performance. (Prefer one runInAction anyway β for atomicity, not speed.)
Seeded stores mean your "No products yet" branch ships untested. Pin a request to an empty snapshot β live data untouched:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty" # β []
Measured in the harness: the empty-state div renders (and note the subtlety β initialize items = null to distinguish "loading" from "loaded zero", or your spinner and your empty state collapse into one).
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset": "ecommerce"}'
That returns a live base URL with seeded products, orders, customers, reviews β full CRUD, filters (?price_gte=100), pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag used above (mock_status, mock_delay, mock_seq, envelope reshaping, chaos). Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.
Written by the Mockbird maker β bias disclosed. MobX comes out of these measurements looking like what it is: the most automatic reactivity in React β when the two manual rules are followed (wrap components in observer, dereference during render). Its failure mode is distinctive: where zustand crashes loudly and jotai sticks on a fallback, MobX's traps are silent freezes over correct data β the forgotten observer() and the early destructure produce working stores and dead UIs with clean consoles. Credit where due: the strict-mode-by-default warning (Β§4) is the loudest post-await tripwire in this series, and flow is a genuinely elegant answer to the async-action problem. The fetching concerns β dedupe, cancellation, retries β are yours to own, same as every store library here; the AbortController-in-the-store (Β§10) and in-flight guard (Β§8) are cheap insurance. Measurements taken Sep 24, 2026 on mobx 7.0.4, mobx-react-lite 5.0.3, React 19.3, Vite 5, client-side rendering, mobx defaults (enforceActions: "observed"). Related: mocking for React, React 19 use(), measured, Zustand, measured, Jotai async atoms, measured, Redux createAsyncThunk, measured, TanStack Query (the library that owns these problems for you).