Global Loading and Errors in React Native

Build a Post Browser app with TanStack Query for global state management.

Introduction

30 Second Summary

Every app you use shows a loading spinner while data arrives and a helpful message when something goes wrong. Wiring up those states for every screen that fetches data is one of the most repetitive parts of mobile development.

In this project, you will build a Post Browser app in React Native that fetches posts from a REST API. You will wire up loading and error states by hand first, then replace all that boilerplate with TanStack Query's global approach.

What You'll Build

Your finished app shows a scrollable list of posts with author names, a loading overlay that fires the moment any request starts, and an error banner that surfaces the instant anything fails.

By the end of this project, you'll have:

  • A scrollable list of posts, each showing the title, body, and author name fetched from a separate API endpoint.
  • A global loading overlay that activates automatically whenever any API request is in flight, without per-component wiring.
  • A global error banner that surfaces API failures from anywhere in the app, dismissible with a tap.
  • Secret Mission: Add pull-to-refresh and simulate network errors to see TanStack Query's retry behavior in action.

Are there any prerequisites?

This project assumes you are comfortable with basic JavaScript or TypeScript. You should also have a basic understanding of React components and hooks like useState and useEffect.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Expo Project

The Post Browser app will fetch data, display loading spinners, and surface errors. All of that needs a working React Native project as a foundation.

You'll use Expo to create a blank TypeScript project, then verify it runs on the iOS Simulator.

In this step, get ready to:
  • Create a new Expo project with the blank TypeScript template.
  • Start the development server and verify the app runs on the iOS Simulator.
Create a new Expo project

Expo provides project templates that scaffold a working app with the right configuration out of the box. The blank TypeScript template gives you a minimal starting point with no navigation or routing pre-configured.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Expo project by running the command below.
  • The command will prompt What is your app named? Enter PostBrowser.
bun create expo --template blank-typescript@sdk-57

What does this command do?

bun create expo scaffolds a new Expo project with all the configuration files you need. The --template blank-typescript@sdk-57 flag selects the blank TypeScript template pinned to Expo SDK 57.

Expo's default template includes navigation and routing pre-configured. The blank template skips all of that, keeping the project focused on data fetching and state management.

  • Move into the new project folder by running:
cd PostBrowser
  • Confirm the project files were created by running:
ls

You should see App.tsx, app.json, package.json, and tsconfig.json among the listed files.

Don't see these files?

  • Scroll up in your terminal and check for error messages from the bun create expo command. A network error means your internet connection dropped during the download.
  • Make sure you ran cd PostBrowser before running ls. Without it, you're looking at your Desktop instead of the project folder.
  • Help me troubleshoot my Expo project creation.
Start the development server

With the project scaffolded, you can start the Expo development server. It compiles your TypeScript code and sends it to connected devices and simulators.

  • Start the development server by running:
bun expo start

The terminal displays a QR code and a list of keyboard shortcuts. Keep this terminal window open while you work on the project.

Before you open the simulator, what do you expect the default template app to show on screen?

  • Press i in the terminal to open the app in the iOS Simulator.

What should I see?

The first build may take a minute while Expo compiles the app for the simulator. Once it finishes, you should see the text "Open up App.tsx to start working on your app!" centered on a white screen.

Simulator not opening?

  • Make sure Xcode and iOS Simulator are installed on your Mac. The simulator requires Xcode's developer tools.
  • If the simulator opens but shows a blank white screen, wait 30 seconds. The first JavaScript bundle takes time to compile and load.
  • Try pressing i again in the terminal. Sometimes the first attempt times out before the simulator finishes booting.
  • Help me get the Expo app running in the iOS Simulator.

Your project is up and running. Next, you'll write your first data fetch and see real posts from a live API appear on screen.

Fetch and Display Posts

Your Expo project is running and the default template text is on screen. Now it is time to replace that placeholder with real data from the internet.

In this step, you will fetch posts from JSONPlaceholder, a free REST API that returns fake blog posts. You will wire up the fetch using plain useEffect and useState and display the results in a scrollable list.

In this step, get ready to:
  • Create an API layer with type definitions and fetch functions.
  • Build a posts screen that fetches and renders data in a scrollable list.
Create the API layer

Before building any UI, you need a clean place to define what a post and a user look like and how to fetch them. A dedicated api.ts file keeps all API logic out of your components.

  • Create a new file called api.ts in the root of your PostBrowser folder (the same level as App.tsx).
  • Add the base URL constant and the two TypeScript interfaces by pasting this code:
// Base URL for the JSONPlaceholder REST API
const BASE_URL = 'https://jsonplaceholder.typicode.com';

// Shape of a post returned by /posts
export interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

// Shape of a user returned by /users/{id}
export interface User {
  id: number;
  name: string;
  email: string;
  username: string;
}

What does this code do?

BASE_URL stores the root address of the JSONPlaceholder API so you only type it once.

The Post and User interfaces tell TypeScript exactly what fields each API response contains. This gives you autocomplete and catches typos at build time instead of at runtime.

  • Add the two fetch functions below the interfaces in the same file:
// Fetch all 100 posts from the API
export async function fetchPosts(): Promise<Post[]> {
  const response = await fetch(`${BASE_URL}/posts`);
  if (!response.ok) {
    throw new Error(`Failed to fetch posts: ${response.status}`);
  }
  return response.json();
}

// Fetch a single user by their ID
export async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(`${BASE_URL}/users/${userId}`);
  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.status}`);
  }
  return response.json();
}

What do these functions do?

  • fetchPosts() hits the /posts endpoint and returns an array of 100 posts.
  • fetchUser(userId) hits /users/{id} to get one user's details. You will use this in a later step.
  • Both functions check response.ok and throw an error if the request failed. This is important because fetch does not throw on HTTP errors like 404 or 500 by default.
  • Save api.ts.
  • Confirm the file was created by running:
ls api.ts

You should see api.ts printed in the output.

Don't see api.ts?

  • Make sure you created the file in the PostBrowser root folder, not inside a subfolder.
  • Check that the filename is exactly api.ts (lowercase, .ts extension, not .tsx).

Help me find where my api.ts file ended up.

✔️ Awesome, I've got everything!

Great. Make sure you have saved api.ts before moving on.

ⓧ I'd like to double check the full code

Here is the complete api.ts file. Compare yours against it:

const BASE_URL = 'https://jsonplaceholder.typicode.com';

export interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

export interface User {
  id: number;
  name: string;
  email: string;
  username: string;
}

export async function fetchPosts(): Promise<Post[]> {
  const response = await fetch(`${BASE_URL}/posts`);
  if (!response.ok) {
    throw new Error(`Failed to fetch posts: ${response.status}`);
  }
  return response.json();
}

export async function fetchUser(userId: number): Promise<User> {
  const response = await fetch(`${BASE_URL}/users/${userId}`);
  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.status}`);
  }
  return response.json();
}
Build the posts screen

With the API layer ready, you can build the component that actually calls it and puts posts on screen. For now, you will use the simplest approach: call fetchPosts() inside a useEffect hook and store the result in useState.

  • Create a new file called PostsScreen.tsx in the root of your PostBrowser folder.
  • Add the component logic by pasting this code:
import { useEffect, useState } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { fetchPosts, Post } from './api';

export default function PostsScreen() {
  // Store the fetched posts in local state
  const [posts, setPosts] = useState<Post[]>([]);

  // Fetch posts once when the component mounts
  useEffect(() => {
    fetchPosts().then(setPosts);
  }, []);

  return (
    <FlatList
      data={posts}
      keyExtractor={(item: Post) => item.id.toString()}
      contentContainerStyle={styles.list}
      renderItem={({ item }: { item: Post }) => (
        <View style={styles.card}>
          <Text style={styles.title}>{item.title}</Text>
          <Text style={styles.body} numberOfLines={2}>
            {item.body}
          </Text>
        </View>
      )}
    />
  );
}

What does this component do?

  • useState<Post[]>([]) creates an empty array that will hold the fetched posts.
  • useEffect with an empty dependency array [] runs fetchPosts() once when the component first appears on screen.
  • FlatList is React Native's performant scrollable list. It only renders the items currently visible on screen, which matters when you have 100 posts.
  • numberOfLines={2} truncates long post bodies so each card stays compact.
  • Add the styles below the component function in the same file:
const styles = StyleSheet.create({
  list: {
    padding: 16,
  },
  card: {
    backgroundColor: '#f8f9fa',
    borderRadius: 8,
    padding: 16,
    marginBottom: 12,
  },
  title: {
    fontSize: 16,
    fontWeight: 'bold',
    marginBottom: 4,
  },
  body: {
    fontSize: 14,
    color: '#666',
    marginBottom: 8,
  },
});

What do these styles do?

  • list adds padding around the entire scrollable area so cards do not touch the screen edges.
  • card gives each post a light grey background, rounded corners, and spacing between cards.
  • title makes the post title bold and slightly larger than the body text.
  • Save PostsScreen.tsx.
  • Confirm the file exists by running:
ls PostsScreen.tsx

You should see PostsScreen.tsx printed in the output.

✔️ Awesome, I've got everything!

Great. Make sure you have saved PostsScreen.tsx before moving on.

ⓧ I'd like to double check the full code

Your PostsScreen.tsx should contain two sections in this order. Scroll up and confirm each one is present:

  • Component logic: imports at the top, the PostsScreen function with useState, useEffect, and the FlatList return.
  • Styles: a StyleSheet.create call with list, card, title, and body style definitions.
Wire PostsScreen into the app

The new screen exists but the app still renders the default template text. You need to update App.tsx to render PostsScreen instead.

  • Open App.tsx and replace the entire contents of the file with this code:
import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import PostsScreen from './PostsScreen';

export default function App() {
  return (
    // SafeAreaView keeps content within the screen safe area
    <SafeAreaView style={styles.container}>
      <StatusBar style="auto" />
      <PostsScreen />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
});

What changed in App.tsx?

  • The old View and Text are replaced with a SafeAreaView that keeps your content clear of the status bar and notch on iOS.
  • PostsScreen is now the only child component. The app delegates all rendering to this screen.
  • Save App.tsx.

Before you check the simulator, what do you think you will see? Will there be a loading spinner while the data loads, or will it just appear?

  • Switch to the iOS Simulator. Expo's dev server auto-reloads your app on save.

You should see a scrollable list of post cards. Each card shows a bold title and a two-line preview of the body text.

Did you notice what's missing?

The posts appeared, but there was no loading spinner while the data was being fetched. The screen was completely blank until the posts arrived.

There is also no error handling. If the network request fails, the user sees nothing at all. You will tackle both of these problems in the next step.

Not seeing any posts?

  • Check that api.ts and PostsScreen.tsx are in the same folder as App.tsx. The imports use relative paths like ./api and ./PostsScreen.
  • Look for red error text in the simulator or in the terminal running the dev server. A typo in an import path or interface name usually shows up here.
  • Make sure your computer has an active internet connection. The app fetches from a live API at jsonplaceholder.typicode.com.

Help me debug why my PostsScreen is not showing any posts.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete App.tsx:

import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import PostsScreen from './PostsScreen';

export default function App() {
  return (
    <SafeAreaView style={styles.container}>
      <StatusBar style="auto" />
      <PostsScreen />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
});

For api.ts and PostsScreen.tsx, scroll up to the double-check tabs under each file's section and compare your code against the chunks listed there.

You have posts on screen, but the experience has a gap. There is no spinner while data loads, and a failed request leaves the user staring at a blank screen. Next up, you will add loading and error handling by hand to feel exactly how much boilerplate that takes.

Handle Loading and Errors by Hand

Your app fetches and displays posts, but the user experience has a gap. While data loads, the screen is completely blank. If the fetch fails, nothing happens at all.

In this step, you'll fix that by adding a loading spinner and error message to PostsScreen. Then you'll create a second component that also fetches data. This reveals a bigger problem: every data-fetching component needs the same boilerplate code.

In this step, get ready to:
  • Add loading and error states to PostsScreen.
  • Create a UserName component that fetches author data for each post.
  • Observe the duplicated boilerplate across both components.
Add loading and error state to PostsScreen

Right now PostsScreen calls fetchPosts() and dumps the result straight into state. There is no way to know whether data is still loading or if something went wrong.

  • In PostsScreen.tsx, add ActivityIndicator to the react-native import so it reads:
import { ActivityIndicator, FlatList, StyleSheet, Text, View } from 'react-native';
  • Find the component's state and useEffect block. Replace everything from const [posts, setPosts] through the closing }, []); of the useEffect, then add the conditional returns. The full replacement goes between the function opening brace and the return ( for the FlatList:
  // Track loading state and errors
  const [posts, setPosts] = useState<Post[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setIsLoading(true);
    fetchPosts()
      .then(setPosts)
      .catch((e) => setError(e.message))
      .finally(() => setIsLoading(false));
  }, []);

  // Show a spinner while data loads
  if (isLoading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  // Show an error message if the fetch failed
  if (error) {
    return (
      <View style={styles.center}>
        <Text style={styles.error}>Error: {error}</Text>
      </View>
    );
  }

What does this code do?

  • isLoading tracks whether a fetch is in progress. It starts as false, flips to true before the fetch, and flips back in .finally() regardless of success or failure.
  • error stores the error message string when the fetch fails. The .catch() handler grabs the message from the thrown Error.
  • The two if blocks are "early returns." If isLoading is true, the component renders only the spinner. If error has a value, it renders only the error text. The FlatList only renders when data is ready.
  • Scroll down to the StyleSheet.create call. Add these two new style objects before the existing list style:
  center: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  error: {
    color: '#dc3545',
    fontSize: 16,
  },
  • Save PostsScreen.tsx.
  • Check the iOS Simulator. You should see a loading spinner appear briefly in the center of the screen, then the posts list loads in.

Don't see the spinner?

The spinner is fast on a good connection. Try reloading the app (shake the device or press Cmd+D in the simulator, then tap Reload) to see it again.

If you see a red error screen instead, check that ActivityIndicator is included in your react-native import and that the style names styles.center and styles.error match what you added to the StyleSheet.

Still stuck? Help me debug my PostsScreen loading state

Create the UserName component

Each post has a userId field, but no author name. To display who wrote each post, you need a component that fetches the user data from a separate API endpoint.

  • Create a new file called UserName.tsx in your PostBrowser project folder (next to PostsScreen.tsx).
  • Add the component logic by pasting this code:
import { useEffect, useState } from 'react';
import { Text, StyleSheet } from 'react-native';
import { fetchUser, User } from './api';

interface UserNameProps {
  userId: number;
}

export default function UserName({ userId }: UserNameProps) {
  // Same loading/error/data pattern as PostsScreen
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setIsLoading(true);
    fetchUser(userId)
      .then(setUser)
      .catch(() => setError('error'))
      .finally(() => setIsLoading(false));
  }, [userId]);

  if (isLoading) {
    return <Text style={styles.loading}>Loading author...</Text>;
  }
  if (error || !user) {
    return <Text style={styles.name}>Unknown Author</Text>;
  }
  return <Text style={styles.name}>By {user.name}</Text>;
}

What does this code do?

  • UserName accepts a userId prop and calls fetchUser(userId) on mount to look up the author's name.
  • It follows the exact same useState + useEffect + loading/error/data pattern you just wrote in PostsScreen. That repetition is intentional. You'll feel it.
  • Each instance of this component makes its own independent network request. If 10 posts share the same author, that author gets fetched 10 separate times.
  • Add the styles below the component function in the same file:
const styles = StyleSheet.create({
  loading: {
    color: '#999',
    fontSize: 12,
    fontStyle: 'italic',
  },
  name: {
    color: '#555',
    fontSize: 12,
  },
});
  • Save UserName.tsx.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file, then move on to wire it into PostsScreen.

ⓧ I'd like to double check the full code

Scroll up and confirm your UserName.tsx has these sections in order:

  • Imports for useEffect, useState, Text, StyleSheet, fetchUser, and User.
  • UserNameProps interface with a userId: number field.
  • Component function with user, isLoading, and error state, useEffect calling fetchUser, and conditional returns.
  • StyleSheet with loading and name styles.

Now you need to wire UserName into PostsScreen so it renders inside each post card.

  • In PostsScreen.tsx, find this import line:
import { fetchPosts, Post } from './api';
  • Add the UserName import directly below it so both lines read:
import { fetchPosts, Post } from './api';
import UserName from './UserName';
  • Scroll down to the renderItem function inside the FlatList. Find the closing </Text> tag for the body text:
          <Text style={styles.body} numberOfLines={2}>
            {item.body}
          </Text>
  • Add the UserName component directly below that closing </Text> tag, so it looks like this:
          <Text style={styles.body} numberOfLines={2}>
            {item.body}
          </Text>
          <UserName userId={item.userId} />

What does this line do?

Each post object has a userId field linking it to the author. Passing item.userId as a prop tells each UserName instance which user to fetch from the API.

  • Save PostsScreen.tsx.
  • Check the simulator. Each post card should briefly show "Loading author..." in gray italic text, then the author's name appears (e.g. "By Leanne Graham").

Seeing "Unknown Author" for every post?

Make sure your UserName.tsx imports fetchUser from './api' and that the file is saved in the same folder as api.ts.

Also confirm the useEffect dependency array is [userId], not []. An empty array would not trigger the fetch correctly in some cases.

Need more help? Help me debug my UserName component

✔️ Awesome, I've got everything!

Great. Double check you've saved both PostsScreen.tsx and UserName.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm your PostsScreen.tsx has these sections in order:

  • Imports for useEffect, useState, ActivityIndicator, FlatList, fetchPosts, Post, and UserName.
  • Three state variables: posts, isLoading, and error.
  • useEffect with setIsLoading(true), .catch(), and .finally().
  • Conditional early returns for isLoading (spinner) and error (red text).
  • FlatList with <UserName userId={item.userId} /> inside each card.
  • Styles including center, error, list, card, title, and body.
Observe the duplicated boilerplate

The app works, but take a moment to look at what you built. Open PostsScreen.tsx and UserName.tsx side by side.

  • Compare the two files. Both have the same pattern: useState for data, useState for loading, useState for error, a useEffect to trigger the fetch, and conditional returns for each state. That is roughly 10 lines of identical boilerplate per component.

Now think about what is happening at the network level. Before you check, make a prediction: the JSONPlaceholder API has 10 unique users total, and the posts endpoint returns 100 posts. How many network requests do you think the app is making for user data?

  • Check the terminal where your Expo dev server is running. Look at the network activity.

The app fires 100 separate fetch requests for user data. One for every single post, even though many posts share the same author. There is no shared cache between UserName instances.

Why is this a problem?

Two components is already painful. Imagine an app with 10 screens that each fetch data. Every screen needs the same loading/error/data boilerplate copied in. Every component with the same query fires a redundant network request.

This is the exact problem that TanStack Query was built to solve. In the next step, you will replace all of this manual state management with a single useQuery call per component.

You have loading and error handling working, but the cost is clear: duplicated boilerplate and wasted network requests. Next up, you'll install TanStack Query and watch all of this collapse into a few lines of code.

Refactor to TanStack Query

In the last step, you added loading spinners and error messages to both PostsScreen and UserName. You also noticed the cost: both components use nearly identical boilerplate code, and every new component that fetches data would need the same copy-pasted pattern.

TanStack Query is a server state library built to solve exactly this problem. It replaces manual useState/useEffect fetching with a single useQuery hook that handles loading, errors, and caching automatically.

In this step, get ready to:
  • Install @tanstack/react-query and set up the QueryClientProvider.
  • Refactor PostsScreen and UserName to use useQuery.
  • Verify that duplicate user fetches are automatically deduplicated.
Install TanStack Query and set up the QueryClient provider

Before you can use useQuery in your components, you need to install the library and wrap your app in a provider that gives every component access to the query cache.

  • Install @tanstack/react-query by running this command in your terminal:
bun add @tanstack/react-query@5.101.4
  • Confirm the install succeeded by checking the terminal output for a line that includes @tanstack/react-query.

Install failed?

Make sure you are inside the PostBrowser folder when you run the command. If you're in a parent directory, bun won't find your package.json.

Help me debug my @tanstack/react-query install

Now you need to create a QueryClient and wrap your app in a QueryClientProvider. The provider makes the query cache available to every component in your app.

  • Replace the entire contents of App.tsx with this code:
import { useState } from 'react';
import { SafeAreaView, StyleSheet } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import PostsScreen from './PostsScreen';

export default function App() {
  // Create a stable QueryClient instance
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <SafeAreaView style={styles.container}>
        <StatusBar style="auto" />
        <PostsScreen />
      </SafeAreaView>
    </QueryClientProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
});

What does this code do?

  • QueryClient is the brain of TanStack Query. It manages the cache, tracks which queries are running, and coordinates refetches.
  • useState(() => new QueryClient()) creates the client once and keeps it stable across re-renders. Without this, a new client would be created on every render, wiping the cache each time.
  • QueryClientProvider passes the client down to every child component via React context. Any component inside this provider can call useQuery.
  • Save App.tsx.
  • Check the iOS Simulator. The app should still display the list of posts with author names, exactly as before.

Why does it look the same?

You've only added the provider so far. PostsScreen and UserName still use their manual useState/useEffect code. The next two substeps swap those out for useQuery.

Seeing a red error screen?

Double-check that the QueryClientProvider wraps the SafeAreaView, not the other way around. The provider must be the outermost component in the return statement.

Help me fix my QueryClientProvider setup

Refactor PostsScreen to use useQuery

Right now, PostsScreen uses three useState hooks and a useEffect to manage the fetch lifecycle. TanStack Query's useQuery replaces all of that with a single function call.

  • In PostsScreen.tsx, find the first import line:
import { useEffect, useState } from 'react';
  • Replace that line with this import:
import { useQuery } from '@tanstack/react-query';
  • Now find the three useState lines and the entire useEffect block inside the PostsScreen function:
  const [posts, setPosts] = useState<Post[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setIsLoading(true);
    fetchPosts()
      .then(setPosts)
      .catch((e) => setError(e.message))
      .finally(() => setIsLoading(false));
  }, []);
  • Delete all of that code and replace it with this single useQuery call:
  // Fetch posts using TanStack Query
  const { data: posts, isPending, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

What does this code do?

  • queryKey: ['posts'] gives this query a unique identity. TanStack Query uses this key to cache the result and know when to refetch.
  • queryFn: fetchPosts tells TanStack Query which function to call to get the data. It reuses the same fetchPosts function you already wrote in api.ts.
  • data: posts aliases the returned data as posts so the rest of the component can use it without any changes.
  • isPending, isError, and error replace the three manual state variables you just deleted.

The rendering logic below the query still references the old variable names. You need to update two conditionals to match the new names from useQuery.

  • Find this line in PostsScreen.tsx:
  if (isLoading) {
  • Replace isLoading with isPending, so the line now reads:
  if (isPending) {
  • Next, find this error conditional block:
  if (error) {
    return (
      <View style={styles.center}>
        <Text style={styles.error}>Error: {error}</Text>
      </View>
    );
  }
  • Replace it with this updated block that uses isError and error.message:
  if (isError) {
    return (
      <View style={styles.center}>
        <Text style={styles.error}>Error: {error.message}</Text>
      </View>
    );
  }

Why isError and error.message?

In the manual version, error was a plain string you set yourself. With useQuery, error is the full Error object thrown by your fetch function. You check isError to see if a failure occurred, and access error.message to display the text.

  • Save PostsScreen.tsx and check the iOS Simulator.
  • You should see the same scrollable list of posts with a loading spinner while data loads, exactly as before.

Seeing a type error or blank screen?

Make sure you deleted the entire useEffect block, including the closing }, []); line. Leftover brackets cause syntax errors that crash the app.

Also check that the first import line now says @tanstack/react-query and does not still import useEffect or useState from react.

Help me debug my PostsScreen useQuery refactor

✔️ Awesome, I've got everything!

Great. Double check you've saved PostsScreen.tsx before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm these pieces are in place, in order:

  • The import line reads import { useQuery } from '@tanstack/react-query'; with no useEffect or useState import from react.
  • Inside the function, a single useQuery call with queryKey: ['posts'] and queryFn: fetchPosts. No useState or useEffect remains.
  • The loading check uses isPending (not isLoading).
  • The error check uses isError and displays error.message.
  • The FlatList, styles, and UserName usage are unchanged from before.
Refactor UserName to use useQuery

UserName has the same manual fetching pattern. Replacing it with useQuery not only removes the boilerplate but unlocks something powerful: automatic request deduplication.

  • Replace the entire contents of UserName.tsx with this code:
import { useQuery } from '@tanstack/react-query';
import { Text, StyleSheet } from 'react-native';
import { fetchUser } from './api';

interface UserNameProps {
  userId: number;
}

export default function UserName({ userId }: UserNameProps) {
  // Query key includes userId so each user is cached separately
  const { data: user, isPending } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isPending) {
    return <Text style={styles.loading}>Loading author...</Text>;
  }

  return <Text style={styles.name}>By {user?.name}</Text>;
}

const styles = StyleSheet.create({
  loading: {
    color: '#999',
    fontSize: 12,
    fontStyle: 'italic',
  },
  name: {
    color: '#555',
    fontSize: 12,
  },
});

What does this code do?

  • queryKey: ['user', userId] creates a unique cache entry per user. All 100 post cards render a UserName component, but since JSONPlaceholder only has 10 unique users, TanStack Query fires 10 network requests instead of 100.
  • When multiple UserName components share the same userId, they share the same cached result. The second, third, and tenth component with userId: 1 get the cached user instantly with zero network cost.
  • user?.name uses optional chaining because data can be undefined until the query succeeds.

Why TanStack Query instead of a custom hook?

You could extract the manual fetching boilerplate into a custom useFetch hook. That would reduce code duplication, but you would still need to build caching, request deduplication, background refetching, and retry logic yourself.

TanStack Query gives you all of that out of the box. The query key system means you get a smart, identity-based cache that knows which data is fresh and which is stale.

Before you check the simulator, take a moment to predict what you'll see. The app was already showing posts with author names. Do you think anything will look different now?

  • Save UserName.tsx and check the iOS Simulator.

The app looks and behaves identically to before. Posts load with a spinner, then each card shows its title, body, and author name. The difference is invisible to the user but dramatic in the code.

Where did all the code go?

Open PostsScreen.tsx and UserName.tsx side by side with what you had before. Each file lost roughly 10 lines of manual state management. Those three useState hooks, the useEffect with .then/.catch/.finally, and the manual state toggling are all gone.

Behind the scenes, TanStack Query is also deduplicating network requests. Instead of 100 separate fetches for user data (one per post card), only 10 fire (one per unique userId). Every component with the same query key shares the cached result.

Author names not showing?

Make sure the queryFn in UserName.tsx is an arrow function: () => fetchUser(userId). Writing queryFn: fetchUser(userId) without the arrow calls the function immediately instead of passing it to TanStack Query.

Help me debug my UserName useQuery refactor

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete UserName.tsx:

import { useQuery } from '@tanstack/react-query';
import { Text, StyleSheet } from 'react-native';
import { fetchUser } from './api';

interface UserNameProps {
  userId: number;
}

export default function UserName({ userId }: UserNameProps) {
  const { data: user, isPending } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isPending) {
    return <Text style={styles.loading}>Loading author...</Text>;
  }

  return <Text style={styles.name}>By {user?.name}</Text>;
}

const styles = StyleSheet.create({
  loading: {
    color: '#999',
    fontSize: 12,
    fontStyle: 'italic',
  },
  name: {
    color: '#555',
    fontSize: 12,
  },
});

Here is the complete App.tsx:

import { useState } from 'react';
import { SafeAreaView, StyleSheet } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import PostsScreen from './PostsScreen';

export default function App() {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <SafeAreaView style={styles.container}>
        <StatusBar style="auto" />
        <PostsScreen />
      </SafeAreaView>
    </QueryClientProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
});

For PostsScreen.tsx, scroll up and confirm these pieces are in place:

  • The useQuery import from @tanstack/react-query (no useEffect or useState from react).
  • The useQuery call with queryKey: ['posts'] and queryFn: fetchPosts.
  • isPending in the loading check, isError and error.message in the error check.
  • The FlatList and styles unchanged from before.

Your app now uses TanStack Query for all data fetching, with automatic caching and request deduplication. Next up, you'll take this even further by building a global loading overlay and error banner that cover every API call in the entire app automatically.

Add Global Loading and Error Handling

In the last step, you replaced manual state with useQuery and watched the duplicated boilerplate vanish from each component. But loading spinners and error messages are still handled inside each component individually.

TanStack Query tracks every active query in the app. It provides useIsFetching, a hook that returns the count of queries currently fetching anywhere. Combined with QueryCache's global onError callback, you can build one loading overlay and one error banner that cover the entire app automatically.

In this step, get ready to:
  • Build a global loading overlay using useIsFetching.
  • Build a global error banner using QueryCache onError.
  • Wire both components into the app layout.
Create the global loading indicator

useIsFetching returns a number: the count of queries currently fetching anywhere in the app. When it returns 0, nothing is loading. When it returns anything greater than 0, at least one query is in flight.

  • Create a new file called GlobalLoadingIndicator.tsx in your PostBrowser project root (the same folder as App.tsx) and paste the following code:
import { useIsFetching } from '@tanstack/react-query';
import { ActivityIndicator, StyleSheet, View } from 'react-native';

export default function GlobalLoadingIndicator() {
  // Returns the number of queries currently fetching
  const isFetching = useIsFetching();

  // 0 is falsy, so no overlay when idle
  if (!isFetching) return null;

  return (
    <View style={styles.overlay}>
      <ActivityIndicator size="large" color="#ffffff" />
    </View>
  );
}

const styles = StyleSheet.create({
  overlay: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
    justifyContent: 'center',
    alignItems: 'center',
    zIndex: 999,
  },
});

What does this code do?

useIsFetching() hooks into TanStack Query's internal tracker. Every useQuery call in the entire app is counted automatically.

StyleSheet.absoluteFillObject stretches the overlay to cover the full screen. The semi-transparent black background dims everything underneath so the white spinner stays visible.

Now you need to render this component in App.tsx so it can detect fetching activity.

  • In App.tsx, find this import line near the top:
import PostsScreen from './PostsScreen';
  • Add this import directly below it:
import GlobalLoadingIndicator from './GlobalLoadingIndicator';
  • Next, scroll down to the JSX and find this line:
        <PostsScreen />
  • Add the loading indicator directly below it, so the two lines look like this:
        <PostsScreen />
        <GlobalLoadingIndicator />
  • Save GlobalLoadingIndicator.tsx and App.tsx.
  • Reload the app in the iOS Simulator (press r in the Expo terminal). You should see a semi-transparent dark overlay with a white spinner flash briefly while the posts load, then disappear once the data arrives.

Don't see the overlay?

  • Make sure GlobalLoadingIndicator is rendered inside QueryClientProvider. The useIsFetching hook only works when it has access to the query context.
  • The overlay disappears very quickly on a fast connection. Try throttling your network in the iOS Simulator under Network Link Conditioner to slow it down.
  • Check that the import path matches exactly: ./GlobalLoadingIndicator with no file extension.

Why isn't my GlobalLoadingIndicator overlay showing up?

Why does this work without per-component wiring?

useIsFetching reads from the same QueryClient that powers every useQuery call in the app. When PostsScreen or UserName fetches data, the count goes up. When the fetch finishes, the count goes down. Your overlay reacts to that count without knowing which component triggered the fetch.

Create the error banner component

The loading overlay covers the happy path. Now you need a component that surfaces errors from any query in the app.

  • Create a new file called ErrorBanner.tsx in your PostBrowser project root and paste the component code:
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';

// Props: the error message to display and a callback to dismiss it
interface ErrorBannerProps {
  message: string;
  onDismiss: () => void;
}

export default function ErrorBanner({ message, onDismiss }: ErrorBannerProps) {
  return (
    <View style={styles.banner}>
      <Text style={styles.text}>{message}</Text>
      <TouchableOpacity onPress={onDismiss}>
        <Text style={styles.dismiss}>Dismiss</Text>
      </TouchableOpacity>
    </View>
  );
}

What does this component do?

  • ErrorBanner is a pure display component. It receives a message string and an onDismiss callback as props.
  • TouchableOpacity gives the dismiss button a press animation. Tapping it calls onDismiss to clear the error from the parent's state.
  • Now add the styles directly below the component in the same file:
const styles = StyleSheet.create({
  banner: {
    backgroundColor: '#dc3545',
    padding: 12,
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
  },
  text: {
    color: '#ffffff',
    flex: 1,
    fontSize: 14,
  },
  dismiss: {
    color: '#ffffff',
    fontWeight: 'bold',
    marginLeft: 12,
    fontSize: 14,
  },
});

What do these styles do?

The banner uses flexDirection: 'row' to place the error message and dismiss button side by side. The flex: 1 on the text lets the message take all available space, pushing the dismiss button to the right edge.

  • Save ErrorBanner.tsx.
Wire up global error handling in App.tsx

QueryCache is the object that stores every query's cached result. It accepts an onError callback that fires whenever any query fails, no matter which component triggered it.

You will pass a QueryCache with an onError callback to the QueryClient constructor so that errors from any query surface in the ErrorBanner.

  • Replace the entire contents of App.tsx with the following code. Start by pasting the imports and the component logic:
import { useState } from 'react';
import { SafeAreaView, StyleSheet } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import {
  QueryClient,
  QueryClientProvider,
  QueryCache,
} from '@tanstack/react-query';
import PostsScreen from './PostsScreen';
import GlobalLoadingIndicator from './GlobalLoadingIndicator';
import ErrorBanner from './ErrorBanner';

export default function App() {
  // Holds the latest error message from any failed query
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const [queryClient] = useState(
    () =>
      new QueryClient({
        // QueryCache onError fires whenever ANY query fails
        queryCache: new QueryCache({
          onError: (error) => {
            setErrorMessage(error.message);
          },
        }),
      })
  );

What changed in the QueryClient setup?

  • QueryCache is now imported alongside QueryClient and QueryClientProvider.
  • The QueryClient now receives a queryCache option. The onError callback inside it captures the error message into React state, which triggers a re-render and shows the banner.
  • errorMessage starts as null (no error). When a query fails, setErrorMessage stores the error text so the banner can display it.
  • Now paste the JSX return and styles directly below the code you just added:
  return (
    <QueryClientProvider client={queryClient}>
      <SafeAreaView style={styles.container}>
        <StatusBar style="auto" />
        {/* Show error banner when any query fails */}
        {errorMessage && (
          <ErrorBanner
            message={errorMessage}
            onDismiss={() => setErrorMessage(null)}
          />
        )}
        <PostsScreen />
        <GlobalLoadingIndicator />
      </SafeAreaView>
    </QueryClientProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
});

What does the JSX do?

  • errorMessage && ( conditionally renders the ErrorBanner only when an error exists. When the user taps dismiss, setErrorMessage(null) clears the error and hides the banner.
  • GlobalLoadingIndicator is rendered after PostsScreen so the overlay sits on top of all content.
  • Save App.tsx.

Before you reload, think about this: what do you expect to see when the app loads normally with no network errors?

  • Reload the app in the iOS Simulator (press r in the Expo terminal).

You should see the semi-transparent loading overlay flash while data loads, then the posts list appears. No error banner shows because all requests succeed.

Now test the error banner. What do you think will happen if the API URL is wrong?

  • Open api.ts and find this line at the top:
const BASE_URL = 'https://jsonplaceholder.typicode.com';
  • Temporarily change it to an invalid URL, like this:
const BASE_URL = 'https://jsonplaceholder.typicode.com/invalid';
  • Save api.ts and reload the app.

A red error banner appears at the top of the screen with the failure message and a Dismiss button. The global QueryCache onError callback caught the failure and surfaced it without any per-component error wiring.

  • Change BASE_URL back to the correct URL:
const BASE_URL = 'https://jsonplaceholder.typicode.com';
  • Save api.ts and reload the app one more time to confirm posts load normally again.

Error banner not showing up?

  • Make sure you changed the BASE_URL value, not the variable name. The variable must still be called BASE_URL.
  • Check that your QueryClient in App.tsx includes the queryCache option with new QueryCache({ onError: ... }). Without this, errors stay silent.
  • Verify that ErrorBanner is rendered inside SafeAreaView in App.tsx, before PostsScreen.

Why isn't my error banner appearing when I break the API URL?

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: GlobalLoadingIndicator.tsx, ErrorBanner.tsx, and App.tsx.

ⓧ I'd like to double check the full code

Here is the complete code for each file you created or changed in this step. Compare your files against these.

GlobalLoadingIndicator.tsx:

import { useIsFetching } from '@tanstack/react-query';
import { ActivityIndicator, StyleSheet, View } from 'react-native';

export default function GlobalLoadingIndicator() {
  const isFetching = useIsFetching();

  if (!isFetching) return null;

  return (
    <View style={styles.overlay}>
      <ActivityIndicator size="large" color="#ffffff" />
    </View>
  );
}

const styles = StyleSheet.create({
  overlay: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
    justifyContent: 'center',
    alignItems: 'center',
    zIndex: 999,
  },
});

ErrorBanner.tsx:

import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';

interface ErrorBannerProps {
  message: string;
  onDismiss: () => void;
}

export default function ErrorBanner({ message, onDismiss }: ErrorBannerProps) {
  return (
    <View style={styles.banner}>
      <Text style={styles.text}>{message}</Text>
      <TouchableOpacity onPress={onDismiss}>
        <Text style={styles.dismiss}>Dismiss</Text>
      </TouchableOpacity>
    </View>
  );
}

ErrorBanner.tsx styles (below the component):

const styles = StyleSheet.create({
  banner: {
    backgroundColor: '#dc3545',
    padding: 12,
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
  },
  text: {
    color: '#ffffff',
    flex: 1,
    fontSize: 14,
  },
  dismiss: {
    color: '#ffffff',
    fontWeight: 'bold',
    marginLeft: 12,
    fontSize: 14,
  },
});

App.tsx (imports and component logic):

import { useState } from 'react';
import { SafeAreaView, StyleSheet } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import {
  QueryClient,
  QueryClientProvider,
  QueryCache,
} from '@tanstack/react-query';
import PostsScreen from './PostsScreen';
import GlobalLoadingIndicator from './GlobalLoadingIndicator';
import ErrorBanner from './ErrorBanner';

export default function App() {
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const [queryClient] = useState(
    () =>
      new QueryClient({
        queryCache: new QueryCache({
          onError: (error) => {
            setErrorMessage(error.message);
          },
        }),
      })
  );

App.tsx (JSX return and styles):

  return (
    <QueryClientProvider client={queryClient}>
      <SafeAreaView style={styles.container}>
        <StatusBar style="auto" />
        {errorMessage && (
          <ErrorBanner
            message={errorMessage}
            onDismiss={() => setErrorMessage(null)}
          />
        )}
        <PostsScreen />
        <GlobalLoadingIndicator />
      </SafeAreaView>
    </QueryClientProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
});

Your app now handles loading and errors globally. Every query in the entire app is covered by a single loading overlay and a single error banner, with zero per-component wiring. Next up, you'll extend the app with pull-to-refresh and see how TanStack Query handles retries when things go wrong.

Secret mission

Pull-to-Refresh and Error Simulation

Your app handles global loading and errors. Can you add pull-to-refresh and a deliberate error simulator to watch TanStack Query's retry behavior in action?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses a free public API, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Expo development server (running in your terminal).
  • PostBrowser project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with TanStack Query further.

  • Your Expo dev server stays running in the terminal. You can reload the app in the iOS Simulator at any time.
  • Your PostBrowser folder remains on your Desktop, ready to open and edit.

Pause - I'll come back to this later

Stop the running process to free up memory, but keep your project files so you can pick up where you left off.

  • Switch to the terminal running the Expo dev server and press Ctrl+C to stop it.
  • Your PostBrowser folder stays on your Desktop. When you are ready to resume, open a terminal, cd into the folder, and run bun expo start again.

Delete - I don't want to use this again

Remove all project files and stop the running process to start completely fresh.

  • Switch to the terminal running the Expo dev server and press Ctrl+C to stop it.
  • Delete the PostBrowser project folder by running this command:

macOS

cd ~/Desktop
rm -rf PostBrowser

Windows

cd ~\Desktop
rmdir /s /q PostBrowser

Nice Work!

Nice Work!

Nice work! You just built a production-grade React Native app that handles loading, errors, and data fetching the way real apps do.

You've learned how to:

  • Replace manual useState/useEffect data fetching with TanStack Query's useQuery hook, cutting boilerplate and gaining automatic caching and request deduplication.
  • Build a global loading overlay with useIsFetching that activates automatically whenever any API request is in flight, without wiring it into each component.
  • Build a global error banner powered by QueryCache's onError callback that surfaces failures from any query in the entire app.
  • Secret Mission: Added pull-to-refresh and simulated network errors to see TanStack Query's automatic retry behavior in action.

Ready to quiz yourself?