Introduction: Instant Feedback for Progress Updates

Welcome back! In the previous lesson, you learned how to fetch and display your personalized reading shelf using data from a NestJS API. Now, let’s take the next step: allowing users to update their reading progress and see those changes instantly.

When you update your progress in a book, you want to see the new page number right away, not wait for the server to respond. This is where the concept of Optimistic UI comes in. With Optimistic UI, your app updates the interface immediately, assuming the server will succeed. If something goes wrong, the app can roll back to the previous state.

This approach makes your app feel much faster and more responsive, which is especially important for interactive features like progress tracking.

Shelf and Progress Features

To understand how progress editing fits in, let’s revisit the MyShelfPage component. It renders your shelf entries and, for each book, includes a ProgressEditor. The shelf itself still uses React Query’s useQuery, but now every entry has an input and button that let the user edit their page progress.

src/features/shelf/MyShelfPage.tsx (excerpt)

  • The shelf page is responsible for fetching and rendering data. Each entry shows the title, author, and current stats.
  • ProgressEditor is a new component dedicated to editing progress.
  • Each shelf item passes its data (item) down, so the editor knows which book and what the current page is.
  • The parent page still handles fetching the shelf and rendering each entry in a list.

With this setup, we isolate editing logic from the shelf display logic. The shelf page focuses only on displaying data, while the editor handles the interactions.

The `updateProgress` API

Before looking at the editor, let’s examine the API helper that makes progress updates possible. It’s defined alongside the existing getShelf function.

src/api/reading.ts

  • This function wraps a PATCH request to the /reading/progress backend route.
  • It expects a body shaped like UpdateProgressPayload, which includes userId, bookId, currentPage, and optionally a status.
  • By centralizing this in the API layer, our components don’t worry about HTTP details or headers.

This small function becomes the foundation of all progress editing features in the UI.

Deep Dive: Understanding Mutations with `useMutation`

So far, you’ve seen how we fetch data with useQuery. But updating data works differently. That’s where mutations and React Query’s useMutation hook come in.

What is a Mutation?

A mutation is any operation that changes data on the server.
Examples in this project:

  • Updating your reading progress for a book.
  • Adding a new book to your shelf.

In contrast, a query only reads data:

  • Fetching your current shelf.
  • Looking up the catalog of books.

👉 Think of it like this:

OperationTypeReact Query HookExample in this app
Read dataQueryuseQuerygetShelf
Change dataMutationuseMutationupdateProgress / add to shelf
Why useMutation?

useMutation is specialized for write operations because it:

  • Doesn’t automatically run when the component mounts (unlike useQuery).
  • Provides a mutate function you call manually when you want to trigger the update.
  • Lets you hook into different lifecycle stages (onMutate, onError, onSuccess, onSettled) so you can handle optimistic UI, rollbacks, and refetching.

This is crucial for interactive features like updating progress, where the action happens only when the user clicks Update.

Anatomy of useMutation

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

  • mutationFn → The actual function that calls your API (e.g., updateProgress).
  • onMutate → Runs immediately when you call mutate(). Great for optimistic updates.
  • onError → Runs if the server request fails. Restore previous state here.
  • onSuccess → Runs when the server confirms success.
  • onSettled → Runs whether it succeeds or fails. Often used for invalidateQueries to keep cache fresh.
Query Client + Mutations

Mutations often work hand-in-hand with the Query Client (qc) to keep cached data in sync:

  • Optimistic update:
    qc.setQueryData(...) updates the shelf cache instantly so the UI shows the new progress without waiting.

  • Rollback:
    In onError, restore the previous data snapshot with qc.setQueryData(...).

  • Refetch:
    In onSettled, call qc.invalidateQueries(...) so the app fetches fresh data from the server.

Together, these steps make sure the app feels fast (optimistic update) and reliable (rollback + refetch).

In ProgressEditor, the useMutation hook is wired like this:

When the user updates their page:

  1. mutation.mutate(page) → starts the update.
  2. onMutate → updates the cache immediately.
How Optimistic UI Works in Progress Updates

Let’s look at how the ProgressEditor component handles progress updates using Optimistic UI. Here’s the main part of the code:

Let’s break down what’s happening:

  • When you submit the form, mutation.mutate(page) is called. This triggers the update.

  • The onMutate function is called right away, before the server responds. It:

    • Cancels any ongoing shelf queries to avoid conflicts.
    • Saves the previous shelf data so it can be restored if needed.
    • Updates the shelf data in the cache to show the new page number immediately.
  • The UI updates instantly, so you see your new progress right away.

  • useMutation is used instead of , because progress editing is a (a mutation).

Handling Errors: Rolling Back on Failure

Optimistic UI is powerful, but it must handle errors gracefully. If a network error or validation failure occurs, users shouldn’t be left with incorrect progress numbers. That’s where rollback comes in:

  • If an error occurs, the onError function is called.
  • The ctx object holds the snapshot of the previous state returned from onMutate.
  • If an error occurs, we restore the cache back to this previous state, so the UI goes back to the previous page number.

Example Output:
If you try to update to "Page 30" but the server fails, the UI will quickly switch back to the previous value (for example, "Page 20").

End-to-End Flow

Here’s what happens when you update your reading progress:

  1. The user enters a new page number in ProgressEditor and clicks Update.
  2. The mutation.mutate(page) call triggers updateProgress.
  3. onMutate runs immediately:
    • Cancels queries.
    • Stores the previous shelf snapshot.
    • Updates the cache optimistically with the new page.
  4. The UI updates instantly to show the new progress.
  5. If the server responds with success, React Query invalidates and refetches the shelf to ensure data consistency.
  6. If the server fails, the rollback restores the previous cache, and the UI reflects the original progress.

This flow ensures a balance of speed and reliability, giving users confidence that their actions are reflected without delay.

Adding Books to the Shelf from the Catalog

So far, we have focused on editing progress for books that are already on the shelf. But there’s another key interaction: adding a book from the catalog into the shelf. For this, the app uses a button inside the BookCard component. Each card in the catalog not only shows a book’s title and author, but also includes a conditional button that lets logged-in users add the book to their shelf.

This complements the ProgressEditor we saw earlier: one deals with updating existing progress, the other deals with starting a new shelf entry.

  • useMutation is used again, but this time the mutationFn initializes a shelf entry by calling updateProgress with currentPage: 0 and status: 'want-to-read'.
  • On success, it dispatches a custom event (shelf-changed) and refetches the shelf query so that the UI reflects the new addition.
  • onAdd prevents link navigation (preventDefault, stopPropagation) so clicking the button does not open the book details page. It then triggers the mutation only if the user is authenticated.
Summary and Practice Preview

In this lesson, you implemented optimistic progress editing. You learned how useMutation can update cache data instantly, why it’s important to save and restore previous state, and how to integrate error handling so the UI remains trustworthy. You also saw how this integrates with the existing shelf page to make progress updates seamless.

Next, you’ll practice these skills hands-on: implementing optimistic updates, rolling back on failure, and confirming that your UI remains consistent across state changes. With this, your reading tracker becomes not only accurate but also delightful to use. Well done!

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