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.
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
npx expo install @tanstack/react-query
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
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} />}
/>
);
}
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
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
Wire a screen to a real (or mock) REST API
Replace hardcoded data from an earlier week with live data through TanStack Query.
Requirements:
- A list screen using
useQueryagainst a public test API (e.g. jsonplaceholder.typicode.com) or your own backend. - 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.
- A detail screen keyed by ID, navigated to from the list, using its own cached query.
- A create or update form using
useMutationthat invalidates the list query on success so the new/changed item shows up without a manual refresh. - Confirm that revisiting a previously-viewed detail screen shows cached data instantly, before the background refetch completes.
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?
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?
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?
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?
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.