In project 4 you wrote loading and error state by hand. In a bigger app you do that on many screens, and you also want caching, retries and refresh. TanStack Query (formerly React Query) is the standard library for server data. You describe what to fetch, and it handles state, caching and updates. It is one of the most useful tools to learn for a React Native job.
Concepts in this project
- useQuery: takes a
queryKey(the identity of the data) and aqueryFn(how to fetch it). It returnsdata,isLoading,isError,error,refetchandisRefetching. - Caching: the same key shows cached data instantly.
staleTimesays how long data counts as fresh. - QueryClientProvider: put it at the top of the app, here in the root layout.
- Dynamic routes:
app/[id].tsxmatches any ID. Read it withuseLocalSearchParams. - Mapping messy data: TheMealDB returns ingredients as
strIngredient1,strIngredient2and so on.getMealcollects them into a clean list. - Data source: TheMealDB with its public test key. Check its current terms and that the test key still works before you build.
Create the project
- Use an Expo Router project (see project 3).
- Install the library:
npm install @tanstack/react-query. - Add the files shown.
my-app/
app/
_layout.tsx QueryClientProvider + Stack
index.tsx search and list
[id].tsx recipe detail
src/
api.ts searchMeals and getMeal
`src/api.ts`
export type Meal = { id: string; name: string; thumb: string };
export type MealDetail = Meal & { category: string; area: string; instructions: string; ingredients: string[] };
const BASE = "https://www.themealdb.com/api/json/v1/1";
export async function searchMeals(query: string): Promise<Meal[]> {
const res = await fetch(`${BASE}/search.php?s=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
return (data.meals ?? []).map((m: any) => ({ id: m.idMeal, name: m.strMeal, thumb: m.strMealThumb }));
}
export async function getMeal(id: string): Promise<MealDetail> {
const res = await fetch(`${BASE}/lookup.php?i=${id}`);
if (!res.ok) throw new Error("Could not load the recipe");
const m = (await res.json()).meals?.[0];
if (!m) throw new Error("Recipe not found");
const ingredients: string[] = [];
for (let i = 1; i <= 20; i++) {
const name = (m[`strIngredient${i}`] ?? "").trim();
const measure = (m[`strMeasure${i}`] ?? "").trim();
if (name) ingredients.push(`${measure} ${name}`.trim());
}
return {
id: m.idMeal, name: m.strMeal, thumb: m.strMealThumb,
category: m.strCategory, area: m.strArea, instructions: m.strInstructions, ingredients,
};
}
`app/_layout.tsx`
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router";
const client = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, retry: 1 } } });
export default function Layout() {
return (
<QueryClientProvider client={client}>
<Stack>
<Stack.Screen name="index" options={{ title: "Recipes" }} />
<Stack.Screen name="[id]" options={{ title: "Recipe" }} />
</Stack>
</QueryClientProvider>
);
}
`app/index.tsx`
import { useState } from "react";
import { View, Text, TextInput, FlatList, Image, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { searchMeals } from "../src/api";
export default function Search() {
const router = useRouter();
const [text, setText] = useState("chicken");
const [term, setTerm] = useState("chicken");
const { data, isLoading, isError, error, refetch, isRefetching } = useQuery({
queryKey: ["meals", term],
queryFn: () => searchMeals(term),
});
return (
<View style={styles.screen}>
<TextInput
style={styles.input}
value={text}
onChangeText={setText}
placeholder="Search recipes"
returnKeyType="search"
onSubmitEditing={() => setTerm(text.trim())}
/>
{isLoading && <ActivityIndicator size="large" style={{ marginTop: 32 }} />}
{isError && <Text style={styles.error}>{(error as Error).message}</Text>}
<FlatList
data={data ?? []}
keyExtractor={(m) => m.id}
refreshing={isRefetching}
onRefresh={refetch}
ListEmptyComponent={!isLoading && !isError ? <Text style={styles.empty}>No recipes found.</Text> : null}
renderItem={({ item }) => (
<Pressable style={styles.card} onPress={() => router.push(`/${item.id}`)}>
<Image source={{ uri: item.thumb }} style={styles.thumb} />
<Text style={styles.name}>{item.name}</Text>
</Pressable>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16 },
input: { backgroundColor: "#fff", borderRadius: 10, padding: 12, borderWidth: 1, borderColor: "#d0d4dc", marginBottom: 12 },
error: { color: "#c92a2a", textAlign: "center", marginTop: 20 },
empty: { textAlign: "center", color: "#888", marginTop: 30 },
card: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", borderRadius: 12, marginBottom: 10, overflow: "hidden" },
thumb: { width: 80, height: 80 },
name: { flex: 1, padding: 12, fontSize: 16, fontWeight: "500" },
});
`app/[id].tsx`
import { ScrollView, View, Text, Image, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { getMeal } from "../src/api";
export default function Detail() {
const { id } = useLocalSearchParams<{ id: string }>();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["meal", id],
queryFn: () => getMeal(id),
enabled: !!id,
});
if (isLoading) return <ActivityIndicator size="large" style={{ marginTop: 40 }} />;
if (isError || !data) return <Text style={styles.error}>{(error as Error)?.message ?? "Something went wrong"}</Text>;
return (
<ScrollView contentContainerStyle={styles.content}>
<Image source={{ uri: data.thumb }} style={styles.image} />
<Text style={styles.title}>{data.name}</Text>
<Text style={styles.meta}>{data.category} · {data.area}</Text>
<Text style={styles.heading}>Ingredients</Text>
{data.ingredients.map((i) => (
<Text key={i} style={styles.text}>• {i}</Text>
))}
<Text style={styles.heading}>Instructions</Text>
<Text style={styles.text}>{data.instructions}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
content: { padding: 16 },
image: { width: "100%", height: 240, borderRadius: 12 },
title: { fontSize: 24, fontWeight: "700", marginTop: 12 },
meta: { color: "#666", marginBottom: 8 },
heading: { fontSize: 18, fontWeight: "600", marginTop: 16, marginBottom: 6 },
text: { fontSize: 15, lineHeight: 22 },
error: { color: "#c92a2a", textAlign: "center", marginTop: 40 },
});
How it works
- The search screen keeps the box text and the submitted
termseparately. Only when the user submits doestermchange, which changes the query key and triggers a new fetch. - Going back to a term you already searched shows cached results immediately.
- Pulling the list down calls
refetch.isRefetchingdrives the spinner. - The detail screen uses
enabled: !!idso the query only runs when the ID exists.
Expected output
The app opens on a Recipes screen and searches "chicken" by itself. A list of recipes with pictures appears. Search for "pasta" and press the search key: a new list loads. Search for "chicken" again: the old list appears instantly from the cache. Tap a recipe to see a large picture, its category and area, the ingredients with measures and the instructions. Turn on airplane mode and search a new word: the app shows an error message and does not crash.
Your task
- Build the app and search three words, then repeat the first one.
- Add a Retry button to the error state.
- Add a heart button that stores favourite IDs in AsyncStorage and a Favourites screen.
- Turn on the React Native debugging tools and watch the query states while you search.
How to check it worked
- Repeated searches show cached data at once.
- Pull to refresh reloads the list.
- You can explain what a query key is.