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.
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.
ProgressEditoris 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.
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
PATCHrequest to the/reading/progressbackend route. - It expects a
bodyshaped likeUpdateProgressPayload, which includesuserId,bookId,currentPage, and optionally astatus. - 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.
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:
useMutation is specialized for write operations because it:
- Doesn’t automatically run when the component mounts (unlike
useQuery). - Provides a
mutatefunction 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.
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 callmutate(). 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 forinvalidateQueriesto keep cache fresh.
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:
InonError, restore the previous data snapshot withqc.setQueryData(...). -
Refetch:
InonSettled, callqc.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:
mutation.mutate(page)→ starts the update.onMutate→ updates the cache immediately.
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
onMutatefunction 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.
-
useMutationis used instead of , because progress editing is a (a mutation).
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
onErrorfunction is called. - The
ctxobject holds the snapshot of the previous state returned fromonMutate. - 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").
Here’s what happens when you update your reading progress:
- The user enters a new page number in
ProgressEditorand clicks Update. - The
mutation.mutate(page)call triggersupdateProgress. onMutateruns immediately:- Cancels queries.
- Stores the previous shelf snapshot.
- Updates the cache optimistically with the new page.
- The UI updates instantly to show the new progress.
- If the server responds with success, React Query invalidates and refetches the shelf to ensure data consistency.
- 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.
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.
useMutationis used again, but this time themutationFninitializes a shelf entry by callingupdateProgresswithcurrentPage: 0andstatus: '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. onAddprevents 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.
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!
