← All guides

Mock a REST API for Ionic React β€” useIonViewWillEnter vs useEffect, infinite scroll, pull-to-refresh, and capacitor:// CORS

Ionic apps have a mock-data problem that plain web apps don't: the same code has to run in three places β€” ionic serve in a desktop browser, a capacitor://localhost WebView on iOS, and https://localhost on Android. A hardcoded array works in all three but exercises none of your fetch/loading/error code. A local json-server works in exactly one of them (the phone can't see your laptop's localhost without adb reverse-forwarding or LAN IP gymnastics). A hosted mock with open CORS works in all three unchanged β€” and it makes Ionic's specific failure modes reproducible, which is what most of this guide is about.

Every snippet below was run in a real Chrome (Vite dev server, Ionic React 9.0.4, React 19, react-router-dom 6.30) against the live demo endpoints before publishing. The numbers quoted β€” 1 effect run vs 3 view enters, 6 keystrokes β†’ 1 request, the spinner stuck 4 seconds after its fetch finished β€” are from those runs.

1. Try it in 10 seconds (no signup)

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

That's a shared, self-resetting demo project β€” the API used in every snippet below. When you want your own schema it's one curl or one click; see Β§10.

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

2. Setup: Ionic React 9 pairs with react-router v6 now

If you're following an older tutorial, this is the first thing that will bite you: @ionic/react-router@9 declares a peer dependency of react-router-dom >=6.4 <7. The v5 idioms in most Ionic content β€” <Route component={...}>, useHistory() β€” don't exist in v6. Routes take element, navigation is useNavigate() (or Ionic's own useIonRouter()), and redirects are <Navigate>:

npm i @ionic/react @ionic/react-router react-router-dom@6 ionicons
import { setupIonicReact, IonApp, IonRouterOutlet } from '@ionic/react'
import { IonReactRouter } from '@ionic/react-router'
import { Route, Navigate } from 'react-router-dom'
import '@ionic/react/css/core.css'

setupIonicReact()

const App = () => (
  <IonApp>
    <IonReactRouter>
      <IonRouterOutlet>
        <Route path="/products" element={<ProductsPage />} />
        <Route path="/" element={<Navigate to="/products" replace />} />
      </IonRouterOutlet>
    </IonReactRouter>
  </IonApp>
)

One constant for the API base, used everywhere below:

const API = 'https://mockbird.mockbird.workers.dev/m/demo'

3. The gotcha that defines Ionic data fetching: your useEffect won't re-run

Ionic's router keeps pages mounted in the DOM after you navigate away β€” that's how the native-feeling back-swipe transition works. The consequence: a useEffect(..., []) fetch runs once per mount, and re-entering the page is not a mount. Your list silently shows the data from the first visit, forever.

We counted it. A page with both hooks, then two round-trips to another page and back:

// The classic React way β€” runs ONCE per mount. Ionic keeps pages mounted.
useEffect(() => { window.effectRuns++ }, [])

// The Ionic way β€” runs every time the view becomes active.
useIonViewWillEnter(() => {
  window.viewEnters++
  fetch(`${API}/products?limit=5`)
    .then(r => r.json())
    .then(setProducts)
})
after initial load:        effectRuns: 1   viewEnters: 1   fetches: 1
after 1st navigate + back: effectRuns: 1   viewEnters: 2   fetches: 2
after 2nd navigate + back: effectRuns: 1   viewEnters: 3   fetches: 3

useEffect ran exactly once across the whole session; useIonViewWillEnter tracked every re-entry. If a search for "ionic useEffect not firing when navigating back" brought you here: this is why, and useIonViewWillEnter (from @ionic/react) is the fix. To make refetch-on-return visible while developing, point the fetch at a mutable endpoint β€” POST something to /products from a terminal, navigate away and back, and watch the list update (or not).

4. Loading, error, and empty states β€” as query params

Each sad path is one query param on the same URL, so you can drill every branch of the UI without touching code:

const load = async (params = '') => {
  setState({ status: 'loading', products: [], error: null })
  try {
    const res = await fetch(`${API}/products?limit=5${params}`)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    setState({ status: 'done', products: await res.json(), error: null })
  } catch (e) {
    setState({ status: 'error', products: [], error: e.message })
  }
}

load()                              // happy path
load('&mock_delay=2000')            // real 2s latency β†’ skeleton screens
load('&mock_status=500')            // server error β†’ error branch
load('&category=no-such-category')  // valid 200, empty array β†’ empty state

The loading branch renders IonSkeletonText rows (we verified 5 skeletons on screen during the 2-second wait, then the real list); the error branch rendered Couldn't load products (HTTP 500).; the no-match filter rendered the empty state. ?mock_delay and ?mock_status are honored on every endpoint β€” more in testing loading and error states.

5. IonInfiniteScroll driven by a real X-Total-Count header

const PAGE_SIZE = 10
const loadPage = async () => {
  if (loadingRef.current) return          // guard against double-fire
  loadingRef.current = true
  const page = pageRef.current + 1
  const res = await fetch(`${API}/products?page=${page}&limit=${PAGE_SIZE}`)
  const items = await res.json()
  setTotal(Number(res.headers.get('X-Total-Count')))
  setProducts(prev => [...prev, ...items])
  pageRef.current = page
  loadingRef.current = false
}

const done = total !== null && pageRef.current * PAGE_SIZE >= total

<IonInfiniteScroll
  disabled={done}
  onIonInfinite={async (ev) => {
    await loadPage()
    ev.target.complete()                  // required, same as the refresher
  }}
>
  <IonInfiniteScrollContent loadingText="Loading more…" />
</IonInfiniteScroll>

Measured run: 10 items β†’ scroll β†’ 20 β†’ scroll β†’ 30/30 with the component's disabled flipping to true, zero duplicate rows, exactly 3 network requests. The mock exposes X-Total-Count via CORS on every list response, so "am I done?" is real data, not a guess at an empty page.

The silent no-fire gotcha, measured: we first ran this page in a desktop-sized viewport where 10 items didn't overflow the content area. Scrolling did nothing β€” ionInfinite never fired, no error anywhere. If your ion-infinite-scroll "doesn't work", check that page one actually overflows ion-content; if it might not, make PAGE_SIZE bigger or fall back to a "Load more" button.

6. Pull-to-refresh, and the spinner that never stops

<IonRefresher slot="fixed" onIonRefresh={async (ev) => {
  await load(2000)          // a real 2s network wait via ?mock_delay=2000
  ev.detail.complete()      // ← forget this and the spinner spins forever
}}>
  <IonRefresherContent />
</IonRefresher>

We ran both versions against a real 2-second response. With complete(): the refresher went refresher-refreshing during the fetch, then refresher-completing and away. Without it: 4 seconds after the request had finished the element still carried refresher-active refresher-refreshing β€” stuck, no warning, and the classic "works in the happy path demo, hangs in production" report. The real latency matters here: with an instant local mock the spinner disappears so fast you'd never notice you forgot the call.

IonSearchbar has debouncing built in β€” no lodash needed:

<IonSearchbar debounce={300} onIonInput={(ev) => search(ev.detail.value)} />

Measured: typing 6 characters quickly produced exactly 1 network request (for the full string). But debounce alone doesn't fix the other search bug β€” a slow response for an old query landing after the fast response for the current one. We forced that ordering with ?mock_delay on the shorter query and watched the stale results overwrite the fresh ones. The fix is an AbortController per search:

const ctrlRef = useRef(null)
const search = async (q) => {
  ctrlRef.current?.abort()
  ctrlRef.current = new AbortController()
  try {
    const res = await fetch(`${API}/products?q=${encodeURIComponent(q)}`,
      { signal: ctrlRef.current.signal })
    setResults(await res.json())
  } catch (e) {
    if (e.name !== 'AbortError') throw e   // aborted = expected, ignore
  }
}

Re-run with the same forced delay: the stale request ended in AbortError, the current query's results stayed on screen. Being able to choose which request is slow is the whole trick β€” you can't reproduce this race on demand against a uniformly fast backend.

8. Writes that actually persist

// POST β€” the demo really stores it
const res = await fetch(`${API}/products`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Ionic Guide Test', price: 9.99, category: 'test' }),
})
const created = await res.json()   // ← {"…", "id": 31}

// GET it back β€” it's there (unlike JSONPlaceholder-style fake writes)
await fetch(`${API}/products/${created.id}`)   // 200, same record

// and clean up
await fetch(`${API}/products/${created.id}`, { method: 'DELETE' })   // 200

We ran exactly this: created id 31, fetched it back, deleted it, confirmed the follow-up GET 404s. Persistent writes mean your create/edit/delete screens can be built end-to-end before the real backend exists. (The demo resets itself daily; your own project keeps data until you delete it.)

9. Retry logic against a deterministic failure sequence

?mock_seq=503,503,200 makes the endpoint fail exactly twice, then succeed β€” per mock_seq_key, so give each test run a fresh key:

async function fetchWithRetry(url, tries = 3) {
  for (let i = 1; i <= tries; i++) {
    const res = await fetch(url)
    if (res.ok) return res.json()
    if (i < tries) await new Promise(r => setTimeout(r, 300 * 2 ** (i - 1)))
  }
  throw new Error('gave up')
}

await fetchWithRetry(
  `${API}/products?limit=3&mock_seq=503,503,200&mock_seq_key=run-${Date.now()}`)

Measured attempt statuses: [503, 503, 200] β€” success on the third try, with exponential backoff between. There's also ?mock_ratelimit=N for real 429s with a retry-after header, and ?mock_chaos=0.3 for probabilistic failures β€” see the simulation params.

10. A JWT login flow with zero backend

const res = await fetch(`${API}/auth/login`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'pat@example.com', password: 'anything' }),
})
const { token } = await res.json()          // a real signed JWT

const me = await fetch(`${API}/auth/me`, {
  headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json())                      // β†’ { user: { email: 'pat@example.com', … }, token: … }

Any email/password logs in on the demo; the token is a real signed JWT you can store (Capacitor Preferences, not localStorage, on device) and send as a Bearer header. You can set token expiry to 5 seconds to drill your refresh/logout path, or flip a project to protected mode so every endpoint 401s without a token β€” the full matrix is in the mock JWT auth guide.

11. The Capacitor CORS section (read before your first device build)

On a device your app isn't served from http://localhost:5173 anymore. Capacitor serves it from capacitor://localhost on iOS and https://localhost on Android β€” and every fetch carries that origin. Most public and corporate APIs don't include those origins in their CORS config, which is why "works in ionic serve, fails on the phone" is one of the most-asked Ionic questions. The usual workarounds are the CapacitorHttp plugin (which routes requests through native code, bypassing CORS but also bypassing the browser's fetch semantics) or a proxy.

A mock API only helps here if it sends genuinely open CORS. This one does β€” verified response headers, requested with Origin: capacitor://localhost:

access-control-allow-origin: *
access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
access-control-expose-headers: x-total-count, …

Access-Control-Allow-Origin: * satisfies any origin, including Capacitor's schemes, so plain fetch works identically in the browser, on iOS, and on Android β€” no CapacitorHttp, no proxy, no config divergence between dev and device while you're building against the mock. (Your eventual production API still needs its own CORS story; the point is your mock shouldn't be the thing that breaks first on device.)

12. Honest comparison

Hardcoded arrayLocal json-serverMSWMockbird
Works in browser + iOS + Android unchangedβœ” (but fake)βœ— (device can't reach localhost)βœ” in tests; service-worker caveats in WebViewsβœ” (a URL with open CORS)
Exercises real fetch / CORS / headersβœ—partlyβœ— (intercepted in-process)βœ”
Reproduce Ionic's races (stale search, stuck refresher)βœ—middleware to writeβœ” with per-handler codeone query param
Offline on a planeβœ”βœ”βœ”βœ— β€” real network calls
Team/device sharingcopy the filengrok/LAN setupper-checkoutsame URL everywhere

Be clear-eyed: MSW remains the right tool for automated unit tests of a bundled app, and json-server is great when everything stays on one machine. A hosted mock earns its keep the moment a phone, a teammate, or a CI runner needs to hit the same data you do.

13. Your own API in one curl (or one click)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H "content-type: application/json" \
  -d '{"preset":"ecommerce"}'

That returns a live base URL plus an admin key β€” 4 seeded resources, filters, relations, pagination, GraphQL, OpenAPI/Postman/TypeScript exports, snapshots, webhooks. No signup. Or use the one-click dashboard link, or import an OpenAPI spec / db.json / CSV / Postman collection / HAR to mock your exact shapes. Free while in beta.

Related: mock API for React (the non-Ionic hooks patterns), React Native, Flutter, testing loading and error states, and free mock-API tools compared.

Verification: every snippet on this page was run in a real Chrome (Vite dev server, Ionic React 9.0.4, React 19, react-router-dom 6.30.6) against production on 22 Sep 2026. The quoted numbers are from those runs: 1 useEffect run vs 3 useIonViewWillEnter fires across two navigation round-trips; the infinite scroll that never fired in an un-overflowed viewport, then 10β†’20β†’30/30 with zero duplicates in a phone-sized one; the refresher stuck in refresher-refreshing 4 s after its request finished when complete() was omitted; 6 keystrokes β†’ 1 debounced request; attempt statuses [503, 503, 200]; the persisted-then-deleted record id 31. The CORS headers were captured with a real Origin: capacitor://localhost request. If a snippet doesn't work in your app, that's a bug: tell us.