Project 7: Recipe Browser with Query

React Native with Expo: 10 Real Projects · Building Real Apps · 8 min read · Updated

Server state with a cache

  1. The screen asks for the data with a query key.
  2. If the key is already cached and fresh, the cache answers instantly.
  3. Otherwise the query function fetches and cleans the data.
  4. The result is stored in the cache and the screen redraws with it.

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 a queryFn (how to fetch it). It returns data, isLoading, isError, error, refetch and isRefetching.
  • Caching: the same key shows cached data instantly. staleTime says how long data counts as fresh.
  • QueryClientProvider: put it at the top of the app, here in the root layout.
  • Dynamic routes: app/[id].tsx matches any ID. Read it with useLocalSearchParams.
  • Mapping messy data: TheMealDB returns ingredients as strIngredient1, strIngredient2 and so on. getMeal collects 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

  1. Use an Expo Router project (see project 3).
  2. Install the library: npm install @tanstack/react-query.
  3. 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 term separately. Only when the user submits does term change, 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. isRefetching drives the spinner.
  • The detail screen uses enabled: !!id so 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

  1. Build the app and search three words, then repeat the first one.
  2. Add a Retry button to the error state.
  3. Add a heart button that stores favourite IDs in AsyncStorage and a Favourites screen.
  4. 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.

Frequently asked questions

Why use TanStack Query instead of useEffect and fetch?

It gives you loading and error state, caching, retries, refetching and deduplicated requests with very little code. You avoid many bugs that hand-written effects have.

What is a query key?

It is the identity of the data, usually an array such as ["meals", term]. When the key changes, the library fetches again, and when it repeats, it can reuse the cache.

What is staleTime?

It is how long fetched data counts as fresh. During that time the library will not refetch it automatically.