Fetching Data
By the end of this lesson you will be able to load data from a web API into your app, show a spinner while it loads, handle errors politely, and cancel requests the user no longer needs. You will also set up TanStack Query, which handles caching and refreshing for you.
Almost every real app talks to a server: a train app fetches schedules, a food app fetches menus, a bank app fetches your balance. In React Native, you use the same fetch function you know from the browser.
Step 1: fetch in an effect with three states
A request takes time and can fail. So a screen that loads data has three possible states: loading, error and success. Think of ordering at a dhaba: either the food is being cooked, or something went wrong in the kitchen, or the plate is on your table. Your screen must show something sensible for each.
The logic below is pure React plus fetch, so it runs here in the browser against a free practice API. In your app you would render ActivityIndicator, Text and FlatList instead of web elements.
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
function useJson(url) {
const [state, setState] = useState({ status: "loading", data: null, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading", data: null, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error("Server replied " + res.status);
return res.json();
})
.then((data) => setState({ status: "success", data, error: null }))
.catch((err) => {
if (err.name !== "AbortError") setState({ status: "error", data: null, error: err.message });
});
return () => controller.abort();
}, [url]);
return state;
}
function Customers() {
const { status, data, error } = useJson("https://jsonplaceholder.typicode.com/users?_limit=3");
if (status === "loading") return <p>Loading customers...</p>;
if (status === "error") return <p>Could not load customers: {error}</p>;
return (
<ul>
{data.map((u) => <li key={u.id}>{u.name} ({u.address.city})</li>)}
</ul>
);
}
createRoot(document.getElementById("root")).render(<Customers />);What you should see: "Loading customers..." for a moment, then three names: Leanne Graham (Gwenborough), Ervin Howell (Wisokyburgh) and Clementine Bauch (McKenziehaven). Change the URL to /userz and you get "Could not load customers: Server replied 404".
Notice the important details:
fetchonly rejects on network failure. A 404 or 500 still "succeeds", so checkres.okyourself.- The
AbortControllercancels the request when the screen unmounts or the URL changes. Without it, a slow old response could overwrite newer data, or try to update a screen that is gone. - The effect lists
urlas a dependency, so a new URL starts a new request. For a refresher on effects, see useEffect & Side Effects.
Step 2: the same screen in React Native
import { ActivityIndicator, FlatList, Text, View } from "react-native";
import { useJson } from "@/hooks/use-json"; // the hook from Step 1
export default function CustomersScreen() {
const { status, data, error } = useJson(`${process.env.EXPO_PUBLIC_API_URL}/customers`);
if (status === "loading") {
return <ActivityIndicator size="large" style={{ flex: 1 }} />;
}
if (status === "error") {
return <Text style={{ padding: 24, color: "crimson" }}>Could not load customers. {error}</Text>;
}
return (
<FlatList
data={data}
keyExtractor={(c) => String(c.id)}
renderItem={({ item }) => <Text style={{ padding: 16 }}>{item.name}</Text>}
ListEmptyComponent={<Text style={{ padding: 24 }}>No customers yet.</Text>}
/>
);
}What you should see: a spinning circle in the middle of the screen, then the list of customers, or a red message if the server is unreachable.
API addresses on a real phone
This trips up almost every beginner. Your phone is a separate device. If your API runs on your laptop, http://localhost:3000 on the phone means "the phone itself", not your laptop.
| Where the app runs | How to reach a server on your laptop |
|---|---|
| Physical phone on the same Wi-Fi | Use the laptop's local IP, such as http://192.168.1.20:3000 |
| Android emulator | http://10.0.2.2:3000 points to the laptop |
| iOS Simulator (on a Mac) | http://localhost:3000 works |
| Production app | Your real HTTPS domain |
Release builds also block plain http:// addresses by default on both platforms. Always use HTTPS for real servers.
Step 3: put the API address in an environment variable
Do not hard-code the server address in every file. Expo reads .env files, and any variable whose name starts with EXPO_PUBLIC_ is available as process.env.EXPO_PUBLIC_... in your code.
# .env (in the project root)
EXPO_PUBLIC_API_URL=https://api.example.comRestart npx expo start after creating or changing .env.
Step 4: let TanStack Query do the hard parts
Writing loading, error, caching, retries and refreshing by hand for every screen gets repetitive. TanStack Query (also called React Query) does this for you. It keeps a cache: a store of earlier answers, keyed by a name you choose, so revisiting a screen shows data instantly while it checks for fresh data in the background.
npx expo install @tanstack/react-query// src/app/_layout.jsx
import { Stack } from "expo-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack />
</QueryClientProvider>
);
}// src/app/orders.jsx
import { useQuery } from "@tanstack/react-query";
import { ActivityIndicator, FlatList, Text } from "react-native";
async function fetchOrders() {
const res = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/orders`);
if (!res.ok) throw new Error("Server replied " + res.status);
return res.json();
}
export default function Orders() {
const { data, isPending, isError, error, refetch, isRefetching } = useQuery({
queryKey: ["orders"],
queryFn: fetchOrders,
});
if (isPending) return <ActivityIndicator style={{ flex: 1 }} />;
if (isError) return <Text onPress={() => refetch()}>Error: {error.message}. Tap to retry.</Text>;
return (
<FlatList
data={data}
keyExtractor={(o) => String(o.id)}
renderItem={({ item }) => <Text style={{ padding: 16 }}>Order #{item.id}: ₹{item.total}</Text>}
refreshing={isRefetching}
onRefresh={refetch}
/>
);
}What you should see: the first visit shows a spinner, then the orders. Leave the screen and come back: the orders appear instantly from the cache. Pull down to refresh and a fresh request runs. If the request fails, TanStack Query retries a few times before showing the error. The React course covers it in depth in Server State with TanStack Query.
Common mistakes
- Not checking
res.ok. A 404 page is not data. Throw an error whenres.okis false. - Calling
localhostfrom a phone. Use your laptop's IP or10.0.2.2on the Android emulator. - Secrets in
EXPO_PUBLIC_variables. Everything in the bundle is public. - No cleanup in effects. Abort requests when the screen unmounts or inputs change.
- Showing a blank screen while loading. Always show a spinner or placeholder, and a message on error.
Key points
fetchworks the same as on the web; checkres.okbecause HTTP errors do not reject.- Every data screen needs loading, error and success states.
- Cancel stale requests with
AbortControllerin the effect cleanup. - On a phone,
localhostis the phone; use your laptop's IP or10.0.2.2on the Android emulator. EXPO_PUBLIC_variables configure the API address but are visible to everyone.- TanStack Query adds caching, retries and refreshing with
useQuery.
Exercise
Write a custom hook usePosts(userId) that fetches https://jsonplaceholder.typicode.com/posts?userId=ID, with loading, error and success states and an AbortController cleanup. Then write a screen that shows the number of posts and the first post's title, plus a "Next user" button that increases userId (stop at 10).
Show solution
import { useEffect, useState } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
export function usePosts(userId) {
const [state, setState] = useState({ status: "loading", posts: [] });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading", posts: [] });
fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`, { signal: controller.signal })
.then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then((posts) => setState({ status: "success", posts }))
.catch((e) => { if (e.name !== "AbortError") setState({ status: "error", posts: [] }); });
return () => controller.abort();
}, [userId]);
return state;
}
export default function PostsScreen() {
const [userId, setUserId] = useState(1);
const { status, posts } = usePosts(userId);
return (
<View style={{ padding: 24, gap: 12 }}>
<Text>User {userId}</Text>
{status === "loading" && <ActivityIndicator />}
{status === "error" && <Text>Could not load posts.</Text>}
{status === "success" && <Text>{posts.length} posts. First: {posts[0]?.title}</Text>}
<Pressable onPress={() => setUserId((id) => Math.min(10, id + 1))}><Text>Next user</Text></Pressable>
</View>
);
}Because userId is in the dependency list, each tap starts a new request and the cleanup aborts the previous one if it is still running.
Quick Quiz
1. fetch gets a 404 response. What happens by default?
fetch only rejects on network errors, so check res.ok yourself.
2. Your API runs on your laptop at port 3000. What URL should the Android emulator use?
In the Android emulator, 10.0.2.2 points to the host computer.
3. Is it safe to store a payment gateway secret key in EXPO_PUBLIC_PAY_SECRET?
Public variables are embedded in the bundle; keep secrets on the server.
4. What does TanStack Query's cache give you?
Cached query results show immediately, and the library refetches when data may be stale.
Learn React Native with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
