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.