Week 10: API Integration & TanStack Query

A React Native app talks to a backend exactly the way a React web app does — fetch and a URL. What's harder is everything around that one call: showing the right loading state, not re-fetching data you already have, and updating the UI after a write without a manual refresh. TanStack Query handles all of it.

Phase 5 of 8 Week 10 of 22 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Call a REST API from a React Native app with proper error handling
  • Set up TanStack Query and fetch a list and a detail view with useQuery
  • Write data with useMutation and invalidate the right cached queries afterward
  • Render loading, error and empty states correctly for every query

1. Calling a REST API

Plain fetch works identically in React Native — the only mobile-specific wrinkle is that localhost on your laptop is not the emulator's localhost; point at your machine's LAN IP (or 10.0.2.2 on the Android emulator) when developing against a local backend.

api/client.ts
const BASE_URL = 'https://api.example.com';

export async function apiFetch(path: string, options?: RequestInit) {
  const response = await fetch(BASE_URL + path, options);
  if (!response.ok) {
    const body = await response.text();
    throw new Error('API error ' + response.status + ': ' + body);
  }
  return response.json();
}

This site's Node.js & Express course (or the framework of your choice) pairs directly with everything here — the app layer doesn't care whether the backend is Express, Laravel, or a hosted API.

2. Setting Up TanStack Query

terminal
npx expo install @tanstack/react-query
App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <NavigationContainer>{/* ... */}</NavigationContainer>
    </QueryClientProvider>
  );
}

3. Queries: Lists & Details

a list screen
import { useQuery } from '@tanstack/react-query';

function PostsScreen() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () => apiFetch('/posts'),
  });

  if (isPending) return <ActivityIndicator />;
  if (isError) return <Text>Failed to load: {error.message}</Text>;
  if (data.length === 0) return <Text>No posts yet.</Text>;

  return (
    <FlatList
      data={data}
      keyExtractor={(post) => post.id}
      renderItem={({ item }) => <PostRow post={item} />}
    />
  );
}
a detail screen keyed by param
function PostDetailScreen({ route }) {
  const { postId } = route.params;
  const { data, isPending } = useQuery({
    queryKey: ['posts', postId],
    queryFn: () => apiFetch('/posts/' + postId),
  });

  if (isPending) return <ActivityIndicator />;
  return <Text>{data.title}</Text>;
}

TanStack Query caches by queryKey['posts', postId] caches each post separately, so navigating between two posts you've already visited shows cached data instantly while it silently refetches in the background.

4. Mutations & Cache Invalidation

creating a post and refreshing the list
import { useMutation, useQueryClient } from '@tanstack/react-query';

function useCreatePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (newPost: { title: string; body: string }) =>
      apiFetch('/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPost),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

function NewPostScreen() {
  const createPost = useCreatePost();

  return (
    <Pressable
      onPress={() => createPost.mutate({ title: 'Hello', body: 'First post' })}
      disabled={createPost.isPending}
    >
      <Text>{createPost.isPending ? 'Saving...' : 'Save'}</Text>
    </Pressable>
  );
}

invalidateQueries marks the ['posts'] query stale, so any screen currently showing that list refetches automatically — no manual "navigate back and pull to refresh" needed.

5. Hands-on Exercise

Hands-on

Wire a screen to a real (or mock) REST API

Replace hardcoded data from an earlier week with live data through TanStack Query.

Requirements:

  1. A list screen using useQuery against a public test API (e.g. jsonplaceholder.typicode.com) or your own backend.
  2. Correct handling of all three states: a spinner while pending, an error message on failure, and a distinct empty-state message when the list is genuinely empty.
  3. A detail screen keyed by ID, navigated to from the list, using its own cached query.
  4. A create or update form using useMutation that invalidates the list query on success so the new/changed item shows up without a manual refresh.
  5. Confirm that revisiting a previously-viewed detail screen shows cached data instantly, before the background refetch completes.
Hint

If a mutation succeeds but the list screen still shows stale data, double-check the queryKey passed to invalidateQueries matches the list query's key exactly — ['posts'] and ['posts', undefined] are different keys to TanStack Query, and a mismatch means nothing gets invalidated.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does revisiting a post you already viewed show data instantly, even before TanStack Query finishes refetching it?

TanStack Query caches each query's last successful result by its queryKey and renders that cached data immediately on the next mount, while triggering a background refetch to check for updates ("stale-while-revalidate"). The UI never has to sit on a loading spinner for data it already has.

Q2

Why key a detail query as ['posts', postId] instead of just ['posts'] for every post?

A shared key would mean every post's detail view overwrites the same cache entry, so navigating from post 1 to post 2 and back would show post 2's data on post 1's screen until a refetch completes. Including postId in the key gives each post its own independent cache entry.

Q3

What does invalidateQueries actually do — does it delete the cached data immediately?

It marks matching queries as stale rather than deleting them outright — any currently-mounted screen using that query refetches in the background, and the old (now-stale) data stays visible until the new data arrives, avoiding a flash of empty/loading state.

Q4

Why does a local backend reachable at localhost:3000 on your laptop often fail to connect from an Android emulator using the same URL?

The Android emulator runs its own virtual network stack, where localhost refers to the emulator itself, not the host machine. 10.0.2.2 is the emulator's special alias for the host machine's localhost, which is what a local backend needs to be reached through during development.