React Native – Talking to an API

July 22, 20264 min readUpdated 8/24/2026

fetch exists and works exactly as it does in a browser. Then reality arrives: the server is not where you think it is, the network is unreliable in ways a desktop's is not, and every screen owes the user three states instead of one.

localhost means three different things

This is the first wall everybody hits, and it is not a bug in your code.

On the iOS simulator, localhost is your Mac — it shares the host's network stack. On the Android emulator, localhost is the emulator itself, a virtual machine with nothing listening; the host is reachable at the special address 10.0.2.2. On a real phone, neither works: it is a different device on the Wi-Fi and needs your machine's LAN address, which changes with the network and so cannot be written down in advance.

So the API host has to be resolved, not hard-coded:

  const hostUri = Constants.expoConfig?.hostUri;
  const devHost = hostUri?.split(':')[0];

  if (devHost && devHost !== 'localhost' && devHost !== '127.0.0.1') {
    return `http://${devHost}:${API_PORT}`;
  }

  if (Platform.OS === 'android') {
    return `http://10.0.2.2:${API_PORT}`;
  }

  return `http://localhost:${API_PORT}`;
}

The physical-device case is solved by asking Expo. While the dev server is running, hostUri holds the address the phone used to download this bundle — which is, by definition, a route back to your machine. Swap Metro's port for the API's and you have the answer, on any network, without configuration.

A release build has no dev server to ask, so it throws instead of guessing. Failing loudly beats shipping a binary that silently talks to localhost.

One more iOS detail

App Transport Security blocks plain HTTP. A development backend on http://localhost:8085 needs NSAllowsLocalNetworking in the Expo config — which opens local addresses only, rather than NSAllowsArbitraryLoads, which disables HTTPS enforcement for the entire app.

One place that calls fetch

async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const { method = 'GET', body, auth = false, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = options;

  const headers: Record<string, string> = { Accept: 'application/json' };
  if (body !== undefined) headers['Content-Type'] = 'application/json';

  if (auth) {
    const token = await tokenStore.get();
    if (token) headers['Authorization'] = `Bearer ${token}`;
  }

  const timeout = withTimeout(timeoutMs, signal);

Everything goes through one function, so there is exactly one implementation of where the API lives, how the token is attached, how a timeout is enforced and how an error response becomes a thrown error. Calling fetch from a screen scatters all four.

Note await tokenStore.get(). On the web the token came out of localStorage synchronously; here it is in the keychain, so reading it is asynchronous. That single difference ripples through the whole app — lesson 16.

Give every request a deadline

A phone on a weak signal does not fail fast. It hangs — fetch will happily wait for minutes while your spinner spins and the user decides the app is broken.

There is no timeout option, so you build one from AbortController: a timer that aborts the request, cleared when the response arrives. The demo app uses fifteen seconds, long enough for a cold server start and short enough that a dead network is obvious.

⚠️ AbortSignal.any would combine the caller's signal with the timeout in one line, and Hermes does not ship it. Wiring the two together by hand is a small thing that is easy to copy from the browser and find missing.

Distinguish a failure from a cancellation

When a screen unmounts mid-request, the abort is not an error — it is the correct outcome, and showing "something went wrong" for it is wrong. The client rethrows a caller-initiated abort untouched so error.name === 'AbortError' still identifies it, and only converts genuine network failures into its own error type.

Two error types are worth having: one for "the server answered and said no", carrying the status and the response body, and one for "the request never arrived". They lead to different messages — and on a phone the second usually means a lost signal rather than a backend that is down.

Fetching in a screen, properly

Three details, all of which are easy to get wrong:

Cancel on unmount. An AbortController created in the effect and aborted in its cleanup. Without it, a slow first response can land after a fast retry and overwrite fresher data — and in development StrictMode runs every effect twice, so the race is not hypothetical.

Independent requests go in parallel. Promise.all, not three sequential awaits. On a phone that is the difference between a menu that opens and one that appears to hang.

An abort is not a failure. Check the signal before setting an error state, or navigating away paints an error on the way out.

A counter, not a boolean, for retry

The reload trigger in the demo app is a number that increments. A boolean flag has to be reset, and the reset races with the request it triggered; a counter works for the second retry as well as the first.

Loading, empty, error

Every data screen has three states besides success, and the one that gets forgotten is the error branch — which is how you end up with a spinner that spins forever when the backend is down. Building them as components rather than repeating the pattern is the cheapest way to stop forgetting one.

When to reach for a data library

Everything above is what TanStack Query gives you for free, plus caching, deduplication, background refetching and stale-while-revalidate. Doing it by hand once is worth it — the loading flag, the cancellation and the error branch are exactly what the library is doing — and then reach for the library on anything with real caching needs.

What is next

Storing Data on the Device — and why there is no localStorage.