EduFlow

Section 4 · Data and the Device

Fetching Data: Loading, Error and Empty States

20 min3 min readCode playground

Lesson at a glance

Load Trailhead’s trail catalog from a server with fetch, model loading, error, empty and success states explicitly, cancel stale requests, and handle the flaky networks phones live on.

  1. 1Four states, one value
  2. 2Fetching with cleanup
  3. 3Pull to refresh and slow networks
  4. 4Offline is a normal state, not an error
  5. 5When to use a data library

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Up to now, Trailhead’s data has lived on the phone. The next feature, a catalog of trails near you, comes from a server. On a laptop with office Wi-Fi, a request is a formality. On a phone at a trailhead with one bar of signal, it’s the slowest and least reliable thing your app does: requests take seconds, fail halfway, or never come back. Designing for that is most of the work.

Four states, one value

Every screen that loads data has at least four states the user can see: loading, error, empty (it loaded, and there’s nothing), and success. Model them as one value, so impossible combinations can’t happen:

src/lib/types.ts
export type Trail = { id: string; name: string; lengthKm: number; difficulty: 'easy' | 'moderate' | 'hard' };

export type Load<T> =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: T };

Empty isn’t a separate status; it’s success with zero items, and the screen decides how to show it.

The screen starts in loading. A successful response moves it to success, which shows either the list or an empty state. A failure moves it to error, and Retry moves it back to loading. Pull to refresh goes from success back to loading. loading success list or empty state error message + Retry response ok fails Retry refresh
A request moves between explicit states

Fetching with cleanup

src/app/(tabs)/explore.tsx
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native';
import type { Load, Trail } from '../../lib/types';

const API = process.env.EXPO_PUBLIC_API_URL;

async function getTrails(signal: AbortSignal): Promise<Trail[]> {
  const res = await fetch(`${API}/trails?near=current`, { signal });
  if (!res.ok) throw new Error(`Server responded ${res.status}`);
  return res.json();
}

export default function Explore() {
  const [state, setState] = useState<Load<Trail[]>>({ status: 'loading' });
  const [attempt, setAttempt] = useState(0);
  const retry = useCallback(() => setAttempt((n) => n + 1), []);

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: 'loading' });
    getTrails(controller.signal)
      .then((data) => setState({ status: 'success', data }))
      .catch(() => {
        if (controller.signal.aborted) return;
        setState({ status: 'error', message: 'Could not load trails. Check your connection and try again.' });
      });
    return () => controller.abort();
  }, [attempt]);

  if (state.status === 'loading') return <ActivityIndicator style={{ flex: 1 }} size="large" />;

  if (state.status === 'error') {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12, padding: 24 }}>
        <Text style={{ textAlign: 'center' }}>{state.message}</Text>
        <Pressable onPress={retry} accessibilityRole="button"><Text>Try again</Text></Pressable>
      </View>
    );
  }

  return (
    <FlatList
      data={state.data}
      keyExtractor={(t) => t.id}
      renderItem={({ item }) => <Text style={{ padding: 16 }}>{item.name} · {item.lengthKm} km</Text>}
      ListEmptyComponent={<Text style={{ padding: 24 }}>No trails within 50 km. Try a wider search.</Text>}
    />
  );
}

Three details carry the weight. res.ok is checked by hand, because fetch treats a 500 as a successful delivery of bad news. The AbortController cancels the request in the cleanup, so leaving the screen (or retrying) never lets a stale response overwrite a fresh one. And the error message tells the user what to do, rather than printing TypeError: Network request failed.

EXPO_PUBLIC_API_URL comes from a .env file. Variables prefixed with EXPO_PUBLIC_ are inlined into the JavaScript bundle, which also means they’re readable by anyone who unpacks your app. Put URLs there, never secret keys.

Pull to refresh and slow networks

Users expect to drag a list down to reload it. FlatList supports that with two props:

TSX
<FlatList
  // …
  refreshing={refreshing}
  onRefresh={async () => {
    setRefreshing(true);
    try { setState({ status: 'success', data: await getTrails(new AbortController().signal) }); }
    catch { /* keep the old list and show a short "Couldn't refresh" message */ }
    finally { setRefreshing(false); }
  }}
/>

A refresh keeps the old list visible while it loads, which is friendlier than the full-screen spinner of the first load. If the refresh fails, keep the old data and show a short message, instead of replacing a perfectly good list with an error screen.

Flaky networks also argue for automatic retries with a growing delay between attempts (1 s, 2 s, 4 s), so a brief signal drop recovers by itself. Only retry what can succeed later: a timeout or a 503 might, but a 404 or a 401 won’t, so failing fast is kinder. You’ll write that helper in the exercise.

Offline is a normal state, not an error

For a hiking app, no signal is the expected condition for hours at a time. Treat it that way. Show the last list you loaded, with a quiet banner saying when it was updated, instead of an error screen. Let users keep logging hikes, save them on the device, and send them when the connection returns. The community package @react-native-community/netinfo tells you whether the device is connected and notifies you when that changes, which is the moment to retry queued work.

None of that needs to be clever on day one. What matters is the decision: for each screen, what does the user see with no network? Write the answer down before writing the fetch, and design the empty, error and offline states with the same care as the happy path.

When to use a data library

The code above is fine for one screen. Across a real app you’d repeat it everywhere, and you’d still lack caching (revisit a screen and see data instantly), request deduplication, background refetching and retries. TanStack Query handles all of that with useQuery, and its docs include the small React Native setup that refetches when the app returns to the foreground. Learn the manual version first so you know what the library is doing, then use the library in production.

Next: data that has to survive without any network at all, stored on the device.

Key takeaways

  • Model a request as one status value (loading, error or success) instead of several booleans that can contradict each other.
  • fetch only rejects on network failure; check response.ok yourself for 404 and 500 responses.
  • Abort in-flight requests in the effect’s cleanup, because users leave screens before slow requests finish.
  • Every list that loads data needs four designed states, including an empty state and an error state with a retry button.
  • On a phone, localhost is the phone itself; point development builds at your computer’s network address or a deployed API.

Further reading

Check your understanding

3 questions

Question 1 of 3

Trailhead’s API returns 404 Not Found for a deleted trail. What does await fetch(url) do?

Question 2 of 3

A user opens a trail’s detail screen and immediately goes back. The request finishes two seconds later and calls setState. What should the effect have done?

Question 3 of 3

Which state shape best fits Trailhead’s catalog screen?

Practice

Code playground

Retry flaky requests with backoff

Implement withRetry(task, { retries, baseDelayMs, sleep }) for Trailhead’s API calls on bad mountain signal.

  • Call task(attempt), with attempt starting at 1, and resolve with its result.
  • If it rejects, retry up to retries more times. Before each retry, await sleep(ms) where the delays double: baseDelayMs, then 2 × baseDelayMs, then 4 ×… (for 100: 100, 200, 400).
  • Errors with a numeric status from 400 to 499 can’t succeed by retrying: rethrow them immediately. The exceptions are 408 (timeout) and 429 (too many requests), which are retried.
  • Errors with no status (network failures) and 5xx errors are retried.
  • When every attempt fails, reject with the last error.

Always wait with the sleep you were given, so the checks can record the delays.

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Run task(attempt). Retry failures with a doubling delay; give up fast on errors
// that can't succeed later. `sleep` is injectable so tests don't really wait.
async function withRetry(task, { retries = 2, baseDelayMs = 1000, sleep = wait } = {}) {
  return task(1);
}

// Demo: fails twice with a 503, then succeeds.
let calls = 0;
const flaky = async () => {
  calls += 1;
  if (calls < 3) throw Object.assign(new Error('Service unavailable'), { status: 503 });
  return ['Ridge Loop', 'Lake Trail'];
};
withRetry(flaky, { baseDelayMs: 10 }).then((v) => console.log('got', v, 'after', calls, 'calls'), (e) => console.log('failed:', e.message));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • A successful task resolves with its value, without waiting
  • A task that fails twice then succeeds resolves
  • Delays double between attempts (100, 200, 400)
  • After the last attempt, the last error is thrown
  • A 404 is thrown immediately without retrying
  • 429 and 408 responses are retried