Introduction: What Is the User's Shelf?

Welcome to the first lesson of this course on building a modern React frontend with a NestJS API. In this lesson, you will learn how to fetch and display a user's personalized reading shelf. The "shelf" is a feature that lets users keep track of books they are reading, want to read, or have finished. By the end of this lesson, you will understand how to retrieve this shelf data from the backend and show it in the frontend, setting the stage for more advanced features later.

Project Setup And Key Files

To get oriented, let’s look at the main files used in this feature. These are the building blocks that combine backend API calls, type safety, and UI components into a single working page. Each file has a distinct responsibility, which helps keep our project modular and easy to extend. Here is the structure relevant to this unit:

src/
  api/
    reading.ts         # Functions to call the backend API for shelf data
  lib/
    types.ts           # Type definitions for shelf items and parameters
  features/
    shelf/
      MyShelfPage.tsx  # Main page to display the user's shelf
      ShelfFilters.tsx # UI controls for filtering and sorting the shelf
  components/
    Skeleton.tsx       # Loading placeholder component
  App.tsx              # Main app layout and navigation
  • src/lib/types.ts provides strong type definitions for shelf data, ensuring consistency with backend responses.
  • src/api/reading.ts centralizes API calls, so our UI never has to deal with raw HTTP logic.
  • src/features/shelf/MyShelfPage.tsx is the main React page where the shelf is rendered.
  • src/features/shelf/ShelfFilters.tsx contains the dropdowns and buttons that let users change filters.
  • src/components/Skeleton.tsx provides a placeholder while data loads.
  • src/App.tsx manages navigation and adds prefetching so shelf data feels instant.

Together, these files provide everything we need to build a polished and responsive shelf page.

Backend Route: /reading/shelf

Before we start coding, it is important to understand how the backend serves shelf data. The backend provides a protected endpoint that responds only when a valid authentication token is supplied, ensuring users can only access their own data. This endpoint supports query parameters for filtering and sorting, allowing us to provide a dynamic and user-friendly shelf view. Understanding this route is critical because our frontend logic will closely mirror these query options.

  • GET /reading/shelf (Protected: requires authentication)
    • Purpose: Returns the current user’s shelf entries, enriched with book details such as title, author, and progress.
    • Query params (all optional):
      • status: one of 'not-started' | 'in-progress' | 'completed' | 'want-to-read'
      • sortBy: one of 'title' | 'author' | 'updatedAt' | 'progress'
      • order: 'asc' | 'desc'
    • Response shape:
      {
        "bookId": 2,
        "title": "Dune",
        "author": "Frank Herbert",
        "progress": 0.24,
        "currentPage": 100,
        "status": "in-progress",
        "updatedAt": "2025-09-21T12:30:00.000Z"
      }

Note: You can always test the backend routes on your own. During the practices, open a new terminal and send a curl request to the desired route to check the output. For protected routes, you’ll need to log in with the correct credentials and include the access token in your request headers. For example, you can log in as admin and capture the access token like this:

ADMIN_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"admin"}' \
  | sed -E 's/.*"access_token":"?([^",}]+).*/\1/')
echo "ADMIN_TOKEN: ${ADMIN_TOKEN}"

Once logged in, you can use that token to access protected routes. For example, here’s how Alice could view her shelf entries filtered by status and sorted by progress in descending order:

curl -s -H "Authorization: Bearer ${ALICE_TOKEN}" \
  "http://localhost:3000/reading/shelf?status=in-progress&sortBy=progress&order=desc"

We go through this process in detail in our Building the Reading Tracker API with NestJS path, where we defined the backend, created all the routes from scratch, and explained the purpose and usage of each one.

Shelf Types in TypeScript

We now define strong TypeScript types to match the backend response. These types not only give us autocomplete in the editor but also prevent runtime errors by enforcing valid values. With them, our frontend code will always stay in sync with backend expectations.

// src/lib/types.ts
export type ShelfStatus =
  | 'not-started'
  | 'in-progress'
  | 'completed'
  | 'want-to-read';

export interface ShelfItemDto {
  bookId: number;
  title: string;
  author: string;
  progress: number;     // 0..1
  currentPage: number;  // integer
  status: ShelfStatus;
  updatedAt: string;
}

export interface GetShelfParams {
  status?: ShelfStatus;
  sortBy?: 'title' | 'author' | 'updatedAt' | 'progress';
  order?: 'asc' | 'desc';
}
  • ShelfStatus defines the valid reading states a book can have, directly matching backend validation.
  • ShelfItemDto captures the full shape of a shelf entry, including progress and timestamps.
  • GetShelfParams specifies optional filters and sorting, keeping the API call flexible.

By adding these types, we give our components strong guarantees about what kind of data they are working with, reducing bugs and making the code easier to maintain.

How The Shelf API Works
Filter Controls in the UI

To let users filter and sort their shelf view, we introduce a dedicated filter component. This component includes dropdowns for filtering by reading status and sorting by different fields, as well as a button to toggle the sort order. By encapsulating this logic in its own component, we keep the main page clean and make it easier to maintain or enhance filters later.

// src/features/shelf/ShelfFilters.tsx
export default function ShelfFilters({
  status, sortBy, order, onChange,
}: {
  status: string; sortBy: string; order: 'asc' | 'desc';
  onChange: (next: {status?: string; sortBy?: string; order?: 'asc'|'desc'}) => void;
}) {
  return (
    <div className="mb-4 flex gap-2 items-end">
      <select
        value={status}
        onChange={(e) => onChange({ status: e.target.value || undefined })}
        className="bg-slate-800 p-2 rounded-md"
      >
        <option value="">All statuses</option>
        <option value="want-to-read">Want to read</option>
        <option value="in-progress">In progress</option>
        <option value="completed">Completed</option>
        <option value="not-started">Not started</option>
      </select>

      <select
        value={sortBy}
        onChange={(e) => onChange({ sortBy: e.target.value })}
        className="bg-slate-800 p-2 rounded-md"
      >
        <option value="updatedAt">Recent updates</option>
        <option value="title">Title</option>
        <option value="author">Author</option>
        <option value="progress">Progress</option>
      </select>

      <button
        className="bg-slate-700 px-3 py-2 rounded-md"
        onClick={() => onChange({ order: order === 'asc' ? 'desc' : 'asc' })}
      >
        {order === 'asc' ? 'Asc' : 'Desc'}
      </button>
    </div>
  );
}
  • The status dropdown lets users filter by reading phase (in-progress, completed, etc.).
  • The sorting dropdown offers different sorting criteria like title, author, or progress.
  • The button toggles between ascending and descending order.
  • Each change triggers onChange, which updates the parent’s state and re-queries the backend.

This UI layer is simple but powerful, allowing end users to interact with the backend via query parameters seamlessly.

Providing Feedback with Skeleton Loaders

Fetching data can take time, and leaving the UI blank during loading is a poor experience. To fix this, we add a skeleton loader that shows placeholder bars where content will appear. This gives users a sense that content is on the way and keeps the interface responsive.

// src/components/Skeleton.tsx
export default function Skeleton({ lines = 3 }: { lines?: number }) {
  return (
    <div role="status" aria-live="polite" className="space-y-2">
      {Array.from({ length: lines }).map((_, i) => (
        <div key={i} className="h-6 bg-slate-800/80 rounded" />
      ))}
    </div>
  );
}
  • Renders a configurable number of placeholder bars (lines defaults to 3).
  • Applies accessibility attributes like role="status" and aria-live="polite" to announce loading states.
  • Uses consistent background and sizing to match the design language of the app.

By using skeletons, we improve perceived performance and reduce the frustration of waiting for network calls.

Fetching And Displaying Shelf Data In React

Now, let’s see how the frontend fetches and displays this shelf data. The main logic is in MyShelfPage.tsx, which uses React Query to manage data fetching and state.

Here is the key part of the code:

import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { getShelf } from "../../api/reading";
import Skeleton from "../../components/Skeleton";
import ShelfFilters from "./ShelfFilters";

export default function MyShelfPage() {
  const [sp, setSp] = useSearchParams();
  const status = sp.get("status") || "";
  const sortBy = sp.get("sortBy") || "updatedAt";
  const order = (sp.get("order") as "asc" | "desc") || "desc";

  const { data, isLoading, isError } = useQuery({
    queryKey: ["shelf", { status, sortBy, order }],
    queryFn: () => getShelf({ status: status as any, sortBy: sortBy as any, order }),
    keepPreviousData: true,
  });

  // ...rendering code
}

Explanation:

  • We call useSearchParams to synchronize filters with the URL, making the shelf view shareable and bookmarkable.
  • The useQuery hook extracts data, isLoading, and isError from the API call.
  • The queryKey uniquely identifies the query in React Query’s cache, ensuring data is refetched when filters change.
  • queryFn calls our getShelf helper, passing in the current status, sortBy, and order.
  • keepPreviousData: true avoids UI flickers by showing old results until new data arrives.

This design abstracts away manual fetch management and gives us a clean, declarative way to work with server state.

Rendering the shelf:

return (
  <section aria-live="polite">
    <h1 className="text-3xl font-bold mb-2">My Shelf</h1>
    <ShelfFilters status={status} sortBy={sortBy} order={order} onChange={onChange} />
    {isLoading && <Skeleton lines={4} />}
    {isError && <p className="text-red-400">Failed to load your shelf.</p>}
    {!isLoading && !isError && (
      <>
        {data?.length ? (
          <ul className="space-y-3">
            {data.map((item) => (
              <li key={item.bookId} className="bg-slate-800 p-4 rounded-lg">
                <div className="font-semibold text-sky-400">{item.title}</div>
                <div className="text-slate-400">by {item.author}</div>
                <div className="mt-1 text-sm">
                  Page {item.currentPage} • {Math.round(item.progress * 100)}% • {item.status}
                </div>
              </li>
            ))}
          </ul>
        ) : (
          <p className="text-slate-400">Your shelf is empty. Visit the Catalog to add books.</p>
        )}
      </>
    )}
  </section>
);

What happens here:

  • While loading, a skeleton placeholder is shown.
  • If there’s an error, an error message is displayed.
  • If data is loaded and not empty, each book is shown in a list.
  • If the shelf is empty, a message is shown to the user.
Connecting Filters To The Shelf View

The shelf page includes filter controls so users can view books by status, sort order, or other criteria. These controls are in the ShelfFilters.tsx component.

Here’s how the filters work:

<ShelfFilters status={status} sortBy={sortBy} order={order} onChange={onChange} />
  • The status, sortBy, and order values come from the URL search parameters.
  • When a user changes a filter, the onChange function updates the URL, which triggers React Query to refetch the shelf data with the new filters.

Example:

  • If a user selects "Completed" from the status dropdown, the URL updates to include ?status=completed.
  • The shelf list updates to show only completed books.

This approach keeps the UI and the URL in sync, so users can bookmark or share filtered views of their shelf.

Prefetching Shelf Data for Faster Navigation

To make navigation smoother, we add prefetching so shelf data loads before the user even clicks. This makes the shelf feel instant when accessed from the navigation bar. The logic lives inside App.tsx and leverages React Query’s prefetchQuery.

// src/App.tsx (excerpt)
<NavLink
  to="/shelf"
  className={linkStyles}
  onMouseEnter={() => qc.prefetchQuery({
    queryKey: ['shelf', { status: '', sortBy: 'updatedAt', order: 'desc' }],
    queryFn: () => getShelf({ sortBy: 'updatedAt', order: 'desc' }),
  })}
>
  My Shelf
</NavLink>
  • onMouseEnter triggers prefetch before the user clicks.
  • React Query stores the data in cache so it is instantly available when navigating.
  • Uses the same getShelf function for consistency.

This small optimization significantly improves perceived performance and responsiveness.

Deep Dive: Understanding Queries with useQuery

In React Query, a query represents a read-only request for data. Unlike mutations (which change data), queries are all about fetching and caching information from the server.

In this project, fetching the shelf is the most common example of a query.

What is a Query?

A query is any operation that retrieves data.
Examples in this app:

  • Fetching the user’s shelf (getShelf)
  • Loading catalog books
  • Getting user profile info

👉 Compare queries with mutations:

OperationTypeReact Query HookExample in this app
Read dataQueryuseQueryFetching shelf
Change dataMutationuseMutationUpdating progress
Why useQuery?

useQuery is designed to handle data fetching and caching with almost no boilerplate. It takes care of:

  • Running your API call (queryFn)
  • Caching the result (queryKey)
  • Tracking loading and error states (isLoading, isError)
  • Refetching automatically when dependencies change

This means you don’t have to write useEffect + useState + manual fetch logic. Everything is declarative.

Anatomy of useQuery

Here’s the basic structure you’ll see in this project:

const { data, isLoading, isError } = useQuery({
  queryKey: ['shelf', { status, sortBy, order }],
  queryFn: () => getShelf({ status, sortBy, order }),
  keepPreviousData: true,
});
  • queryKey → A unique identifier for the query.

    • Used for caching: if the same key is requested again, React Query reuses the data.
    • Can include parameters so filtered results don’t clash.
  • queryFn → The function that actually fetches the data (here, getShelf).

  • data → The response from your query (shelf items).

  • isLoadingtrue while the request is in flight.

  • isErrortrue if the request fails.

  • keepPreviousData → Keeps the old result visible until the new one arrives, preventing flicker.

Query Lifecycle
  1. Initial load

    • isLoading = true
    • No data yet → show a skeleton loader.
  2. Success

    • data is filled with results.
    • UI renders the shelf.
  3. Error

    • isError = true
    • UI shows a fallback message.
  4. Refetch

    • Happens automatically when queryKey changes (e.g., user selects a different filter).
    • Can also be triggered manually with invalidateQueries.
Example in This Project

In MyShelfPage.tsx, useQuery fetches the shelf like this:

const { data, isLoading, isError } = useQuery({
  queryKey: ['shelf', { status, sortBy, order }],
  queryFn: () => getShelf({ status, sortBy, order }),
  keepPreviousData: true,
});

Here’s what happens:

  1. The component mounts.
  2. React Query runs getShelf with the current filters.
  3. While waiting, the page shows a skeleton loader.
  4. Once the data arrives, the shelf list renders immediately.
  5. If the user changes filters, the queryKey changes → React Query automatically refetches with new params.

Prefetching Queries

Another advantage of queries is that you can prefetch them.
In App.tsx, when a user hovers over the "My Shelf" link, the app prefetches the shelf query. This makes the page load instantly when clicked.

🔑 Key Takeaways

  • Use useQuery for read-only data fetching.
  • queryKey uniquely identifies cached data.
  • React Query handles loading, error, and caching states automatically.
  • Use keepPreviousData for smoother transitions between filters.
  • Queries can be prefetched to make navigation feel instant.
Summary And Practice Preview

In this lesson, you learned how to fetch and display a user's personalized shelf using a React frontend and a NestJS API. You saw how the getShelf function works, how React Query manages data fetching and state, and how filter controls update the shelf view in real time.

Next, you will get hands-on practice with these concepts. You’ll try fetching shelf data, handling loading and error states, and connecting filters to the shelf view yourself. This will help you build confidence in working with real-world data and user interfaces.

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