Introduction: The Need for Dynamic Data in a Catalog App

Welcome to the first lesson of our course, "Fetching Catalog Data". In this lesson, we will focus on how to fetch catalog data from an API and display it in our React app.

In a real-world catalog application, such as a library or bookstore, the list of items (like books) is always changing. New books are added, and old ones might be removed. Because of this, we need a way to fetch the latest data from our backend API and show it to users in real time.

In this lesson, you’ll fetch book catalog data from your API and render it efficiently. We’ll wire providers, define types, create an API helper, and then compare two approaches:

  1. TanStack Query’s useQuery (recommended), and
  2. a manual useEffect + fetch implementation (for contrast).

You’ll learn why useQuery scales better—caching, retries, de-duplication, background refetch, pagination support—while also seeing how to implement the same flow with plain hooks and what trade-offs you accept.

TanStack Query (formerly known as React Query) is a popular library that helps us fetch, cache, and update data in React applications. It makes handling data from APIs much easier and more reliable. In this lesson, you will learn how to use TanStack Query to fetch and display a list of books in your catalog.

Application Data Wiring: Providers, Types, and API Layer

We'll discuss the runtime “plumbing” our app uses to retrieve and display data: the Query Client provider, shared types, and an API module that calls /books. Each file is shown separately with an explanation.

// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './features/auth/AuthContext';
import { router } from './routes/router';
import reportWebVitals from './reportWebVitals';

const container = document.getElementById('root');
if (!container) throw new Error('Root container missing');
const root = ReactDOM.createRoot(container);

const queryClient = new QueryClient();
root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <RouterProvider router={router} />
      </AuthProvider>
    </QueryClientProvider>
  </React.StrictMode>
);

reportWebVitals();

Explanation

  • QueryClient is the app-wide cache/manager for queries.
  • QueryClientProvider exposes TanStack Query to your entire tree (so useQuery works anywhere below it).
  • AuthProvider remains wrapped around your routed app to supply auth state.
  • RouterProvider renders pages that will now consume queries.

This placement ensures every page and component can leverage caching, retries, and background refetching provided by TanStack Query.

// src/lib/types.ts
export interface Book {
  id: string;
  title: string;
  author: string;
  coverImageUrl?: string;
  uploadedAt: string;
}

Explanation

  • Book models the server representation your UI expects.
  • PaginatedBooksResponse matches the API’s paginated envelope for /books (list + counts). Typed responses make your UI and data transforms safer.
// src/api/books.ts
import { apiClient } from "./client";
import { Book } from "../lib/types";

export interface PaginatedBooksResponse {
  items: Book[];
  total: number;
  page: number;
  pageSize: number;
}

export const getBooks = async (): Promise<PaginatedBooksResponse> => {
  const { data } = await apiClient.get("/books");
  return data.data;
};

Explanation

  • The backend responds with an envelope { success, data, ... }.

  • We unwrap the data so components consume a typed PaginatedBooksResponse directly.

  • Centralizing this in api/books.ts avoids duplicating response-shape knowledge in your components.

  • The index.tsx file sets up our React app, including the router and TanStack Query provider.

  • The Book interface defines the shape of a book object.

  • The getBooks function is used to fetch a list of books from our API.

This setup ensures our app is ready to fetch and display data.

Understanding TanStack Query

TanStack Query is a library that helps you manage data fetching in React apps. Instead of writing all the logic for fetching, caching, and updating data yourself, TanStack Query provides hooks that do most of the work for you.

  • Why use it?
    • Caching & de-duplication: Re-using data across screens without re-fetching.
    • Stale-While-Revalidate: Show cached results instantly, then refresh in background.
    • Retries & recovery: Built-in retry on transient failures.
    • Devtools & diagnostics: Inspect cache, queries, and states quickly.

The most important hook you will use is useQuery. This hook lets you fetch data from an API and gives you information about the loading, error, and success states.

Local install (if developing on your machine):

# npm
npm i @tanstack/react-query
# optional devtools for debugging
npm i -D @tanstack/react-query-devtools

CodeSignal environment: Already pre-configured. You don’t need to install or configure anything—just import and use.

Fetching and Displaying Books with useQuery

Let’s see how to use TanStack Query’s useQuery hook to fetch and display books in our catalog.

Here is the main code for our catalog page:

// src/features/catalog/CatalogPage.tsx
import { useQuery } from "@tanstack/react-query";
import { getBooks } from "../../api/books";
import BookCard from "./BookCard";
import Spinner from "../../components/Spinner";

export default function CatalogPage() {
  const { data, isLoading, isError, error, isFetching } = useQuery({
    queryKey: ["books"],        // cache key for this resource
    queryFn: getBooks,          // fetch logic (returns PaginatedBooksResponse)
    // Advanced options you may consider:
    // staleTime: 30_000,       // data is "fresh" for 30s — skip refetch
    // gcTime: 5 * 60_000,      // how long unused data stays in cache
    // refetchOnWindowFocus: true, // refresh when tab regains focus
    // retry: 2,                // retry transient failures twice
  });
  
  if (isLoading) {
    return <Spinner />;
  }

  if (isError) {
    return <p className="text-red-400">Error fetching books: {(error as Error).message}</p>;
  }

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6">Book Catalog</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {data?.items.map((book) => (
          <BookCard key={book.id} id={book.id} title={book.title} author={book.author} />
        ))}
      </div>
    </div>
  );
}

Let’s break down what’s happening:

  • We use the useQuery hook to fetch books.
    • queryKey: ["books"] is a unique key for this query.
    • queryFn: getBooks tells TanStack Query which function to call to fetch the data.
  • The hook returns several values:
    • isLoading: true while the data is being fetched.
    • isError: true if there was an error fetching the data.
    • error: the error object, if any.
    • data: the fetched data (in this case, a list of books).
  • If the data is loading, we show a spinner.
  • If there is an error, we show an error message.
  • If the data is loaded, we display the list of books using the BookCard component.

Example output in the browser:

  • While loading:
    A spinner animation is shown.
  • On error:
    Error fetching books: Network Error
  • On success:
    A grid of book cards, each showing the book’s title and author.
Why not useEffect + fetch? (Manual Approach)

You can build the same screen with React’s built-in hooks. This is instructive, but you’ll re-implement loading/error states, caching, and background updates yourself. While it works, relying on useEffect plus fetch puts a lot of responsibility on you as the developer. Every time you fetch data this way, you must remember to set up local state for loading and error, write a try/catch/finally block, and handle cleanup with an AbortController. This creates a lot of repetitive boilerplate code.

import { useEffect, useState } from "react";
import Spinner from "../../components/Spinner";
import BookCard from "./BookCard";
import type { PaginatedBooksResponse } from "../../api/books";

export default function CatalogPage_Manual() {
  const [data, setData] = useState<PaginatedBooksResponse | null>(null);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    const ctrl = new AbortController();

    async function load() {
      try {
        setLoading(true);
        setErr(null);

        const res = await fetch("/books", { signal: ctrl.signal });
        const text = await res.text();
        const json = text ? JSON.parse(text) : { success: false };
        if (!res.ok || !json?.success) {
          throw new Error(json?.message || `Request failed (${res.status})`);
        }

        setData(json.data as PaginatedBooksResponse);
      } catch (e: any) {
        if (e.name === "AbortError") return; // ignore unmount cancels
        setErr(e?.message ?? "Unknown error");
      } finally {
        setLoading(false);
      }
    }

    load();
    return () => ctrl.abort();
  }, []); // re-run when dependencies (e.g., filters) change

  if (loading) return <Spinner />;
  if (err) return <p className="text-red-400">Error fetching books: {err}</p>;

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6">Book Catalog</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {data?.items.map((b) => (
          <BookCard key={b.id} id={b.id} title={b.title} author={b.author} />
        ))}
      </div>
    </div>
  );
}

What you had to implement manually

  • Loading/Error state orchestration (loading, err, try/catch/finally).
  • Unmount safety with AbortController to avoid setting state on an unmounted component.
  • Envelope parsing and HTTP status handling.
  • No cache: Navigating away and back re-fetches every time.
  • No background refetch, retries, or de-duplication: You’d need to add these yourself.

Why useQuery is preferable

  • Built-in cache (including parameterized keys) with garbage collection.
  • Stale-While-Revalidate: Show data immediately from cache, refresh in background.
  • De-duplication: Multiple components asking for the same data cause only one network request.
  • Retries & focus refetch: Handle real-world network conditions with minimal code.
  • Composable APIs: Pagination (keepPreviousData), prefetching, optimistic updates for mutations, etc.

When useEffect is acceptable

  • Simple one-off fetches that you’ll never revisit (no cache benefit).
  • Extremely custom flows where you want total control and can afford more code.
  • Environments where adding dependencies is prohibited.

For most catalog/data screens, useQuery reduces code and bugs while improving UX.

Caching is another big issue. With useEffect, there’s no memory of past requests. If a user navigates away and then comes back to the catalog, the component fetches everything again, even if nothing has changed. This makes the app feel slower and increases unnecessary network usage. In short, using useEffect for fetching is fine for very simple or one-off requests, but for real applications it quickly becomes tedious, inefficient, and harder to maintain. This is why useQuery is the better tool for the job.

Summary and What’s Next

In this lesson, you learned how to use TanStack Query to fetch and display catalog data in a React app. We covered:

  • Why dynamic data is important for a catalog app.
  • How our app and data are set up.
  • What TanStack Query is and why it’s useful.
  • How to use the useQuery hook to fetch and display books, including handling loading and error states.

You are now ready to practice these concepts. In the next exercises, you will get hands-on experience fetching data and displaying it on your own catalog page. Try experimenting with the code and see how changing different parts affects the output. Good luck!

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal