Fetching Data

React Native ships the Fetch API: where the call goes, the three states every request has, and how to cancel one on unmount.

Syntaxconst response = await fetch(url, { method, headers, body, signal });

fetch is built into React Native. You do not install it and there is no window involved. It is the same Fetch API browsers have, with one difference worth knowing: a native app has no CORS, so a server that blocks browser requests may still answer yours.

Where the call goes

A request belongs in an effect, not in the component body, because the body runs on every render. The effect callback must stay synchronous — React only accepts a cleanup function as its return value — so declare an async function inside it and call it. The two awaiting forms are the same request: const res = await fetch(url) is shorthand for fetch(url).then((res) => ...), and await res.json() is shorthand for res.json().then(...).

The three states

Every request is pending, then either successful or failed. All three need to be representable, or the screen has nothing to show while it waits and nothing to show when it breaks.

What trips people up

fetch resolves for a 404 and a 500. Only a network-level failure rejects, so check response.ok yourself. If the user leaves the screen before the response lands, the component is gone and your setState updates nothing useful; pass an AbortController signal and abort in the effect cleanup, then ignore the rejection whose name is AbortError. Finally, localhost inside the app means the phone itself — point a device at your machine's LAN address.

Example

Example · tsx
import { useEffect, useState } from 'react';
import { ActivityIndicator, FlatList, StyleSheet, Text } from 'react-native';

type Movie = { id: string; title: string };

export default function MovieList() {
  const [movies, setMovies] = useState<Movie[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    // The effect stays synchronous; the async work lives in an inner function.
    async function load() {
      try {
        const response = await fetch('https://api.example.com/movies', {
          signal: controller.signal,
        });
        // A 404 or 500 does not reject, so check the status by hand.
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const json = (await response.json()) as { movies: Movie[] };
        setMovies(json.movies);
        setLoading(false);
      } catch (e) {
        // The screen is already gone on an abort: do not touch state.
        if ((e as Error).name === 'AbortError') return;
        setError((e as Error).message);
        setLoading(false);
      }
    }

    load();
    return () => controller.abort(); // runs when the screen unmounts
  }, []);

  if (loading) return <ActivityIndicator style={styles.fill} />;
  if (error) return <Text style={styles.row}>{error}</Text>;

  return (
    <FlatList
      data={movies}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.title}</Text>}
    />
  );
}

const styles = StyleSheet.create({
  fill: { flex: 1, justifyContent: 'center' },
  row: { padding: 16 },
});

When to use it

  • Load a profile from your API when a screen first mounts.
  • Send a sign-up form to a POST endpoint and surface the server's validation message.
  • Drop an in-flight search request when the user navigates away mid-request.
  • Point a development build at an API running on your own machine.

More examples

A POST with a JSON body

Writes live in a plain module function rather than a component, so a screen, a test or a later caching layer can all call the same function. It also shows the two things people forget: a JSON body has to be stringified, and the thrown error should carry the server's own message so the screen can say something better than "something went wrong".

Example · tsx
// api/notes.ts
export type Note = { id: string; body: string };

export async function createNote(body: string): Promise<Note> {
  const response = await fetch('https://api.example.com/notes', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    // body must be a string: JSON.stringify it yourself.
    body: JSON.stringify({ body }),
  });

  if (!response.ok) {
    // A response body can only be read once, so pick text() or json(), not both.
    const text = await response.text();
    throw new Error(`POST /notes failed with ${response.status}: ${text}`);
  }

  return (await response.json()) as Note;
}

Giving a request a deadline

fetch has no timeout option. On a mobile connection a request can hang for minutes without failing, which looks to the user like a frozen screen, so abort it yourself after a fixed time. The two functions are the same code: async/await is shorthand for the promise chain below it.

Example · tsx
export async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);

  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    // Always clear it, or a fast response leaves a timer that fires later.
    clearTimeout(timer);
  }
}

// Longhand for the same control flow, if you prefer promises:
export function fetchWithTimeoutThen(url: string, ms: number): Promise<Response> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
}

Discussion

  • Be the first to comment on this lesson.