← All guides

MobX + React fetch patterns, measured β€” the observer you forgot, the destructure that kills reactivity, and the silent 111-fetch storm

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.

1. Try it in 10 seconds (no signup)

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.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. The good path, for reference

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.

3. Forget observer() and nothing ever updates β€” silently

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.

4. The strict-mode warning, verbatim (assigning after await)

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.

5. The two fixes: runInAction or flow

// 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()).

6. The destructure that kills reactivity (and says nothing)

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.

7. The load() storm: 111 fetches in 5 seconds, completely silent

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.

8. StrictMode fetches twice; the in-flight guard fetches once

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.

9. The race is in your store: page-2 header, page-1 rows, zero aborts

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.

10. The fix: an AbortController that lives in the store

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.)

11. The 500 that becomes "items.map is not a function"

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…

12. Retry logic you can assert on: exactly 3 fetches

"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.

13. Three runInActions cost one render

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.)

14. The empty state you never see in dev

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).

15. Your own backend in one curl

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.

16. Honest notes

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).

← All guides