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)

{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>
    <div className="mt-2">
      <ProgressEditor item={item} />
    </div>
  </li>
))}
  • 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

export async function updateProgress(body: UpdateProgressPayload) {
  return apiClient.patch("/reading/progress", body);
}
  • 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:

const mutation = useMutation({
  mutationFn: (vars) => apiCall(vars),
  onMutate: async (vars) => {
    // Optimistic UI happens here
  },
  onError: (error, vars, context) => {
    // Rollback if server fails
  },
  onSuccess: (data, vars, context) => {
    // React to success (optional)
  },
  onSettled: () => {
    // Always runs, success or failure
    qc.invalidateQueries({ queryKey: ['shelf'] });
  },
});
  • 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:

const mutation = useMutation({
  mutationFn: (nextPage) =>
    updateProgress({ userId: user.sub, bookId: item.bookId, currentPage: nextPage }),
  onMutate: async (nextPage) => {
    // Cancel queries and optimistically update cache
  },
  onError: (_e, _vars, ctx) => {
    // Rollback to previous state
  },
  onSettled: () => {
    // Refetch shelf to sync with server
    qc.invalidateQueries({ queryKey: ['shelf'] });
  },
});

When the user updates their page:

  1. mutation.mutate(page) → starts the update.
  2. onMutate → updates the cache immediately.
  3. If erroronError rolls back.
  4. After completiononSettled refetches to ensure data is consistent.

🔑 Key Takeaways

  • Use useMutation for write operations.
  • Call mutate() manually when a user action happens.
  • Use onMutate + onError for optimistic UI with rollback.
  • Use invalidateQueries to keep your data fresh after a mutation.
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:

// src/features/shelf/ProgressEditor.tsx
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateProgress } from '../../api/reading';
import { useAuth } from '../auth/AuthContext';

export default function ProgressEditor({ item }) {
  const { user } = useAuth();
  const qc = useQueryClient();
  const [page, setPage] = useState(item.currentPage);

  const mutation = useMutation({
    mutationFn: (nextPage) =>
      updateProgress({ userId: user.sub, bookId: item.bookId, currentPage: nextPage }),
    onMutate: async (nextPage) => {
      await qc.cancelQueries({ queryKey: ['shelf'] });
      const prev = qc.getQueryData(['shelf', { status: '', sortBy: 'updatedAt', order: 'desc' }]);
      qc.setQueryData(['shelf', { status: '', sortBy: 'updatedAt', order: 'desc' }], (old) =>
        old?.map((it) => (it.bookId === item.bookId ? { ...it, currentPage: nextPage } : it))
      );
      return { prev };
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ['shelf'] });
    },
  });

  return (
    <form onSubmit={(e) => { e.preventDefault(); mutation.mutate(page); }}>
      <input
        type="number"
        min={0}
        value={page}
        onChange={(e) => setPage(Number(e.target.value))}
      />
      <button disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Saving…' : 'Update'}
      </button>
    </form>
  );
}

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 useQuery, because progress editing is a write operation (a mutation).

  • mutationFn calls updateProgress with the userId, bookId, and the new currentPage.

  • onMutate runs immediately, before the server responds:

    • It cancels any ongoing shelf queries to prevent conflicts.
    • It retrieves and stores the current shelf data (prev) so it can be restored later.
    • It sets new shelf data in the cache, updating the matching book’s page instantly.
  • onError restores the old data if the server fails.

  • onSettled ensures the cache is invalidated, so a fresh fetch from the server eventually syncs everything.

This flow guarantees immediate visual feedback while still keeping the frontend in sync with the real backend.

Example Output:
If you change the page from 10 to 20 and click "Update," the UI will show "Page 20" immediately, even before the server confirms the change.

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:

// Inside useMutation in ProgressEditor.tsx
onError: (_e, _vars, ctx) => {
  if (ctx?.prev) qc.setQueryData(['shelf', { status: '', sortBy: 'updatedAt', order: 'desc' }], ctx.prev);
},
  • 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.

// src/features/catalog/BookCard.tsx (excerpt)
const addMutation = useMutation({
  mutationFn: () =>
    updateProgress({
      userId: user!.sub,
      bookId: id,
      currentPage: 0,
      status: 'want-to-read',
    }),
  onSuccess: () => {
    window.dispatchEvent(new Event('shelf-changed'));
    shelfQuery.refetch();
  },
});

const onAdd = (e: React.MouseEvent) => {
  e.preventDefault();
  e.stopPropagation();
  if (!user) return;
  addMutation.mutate();
};
  • 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.

This mirrors the optimistic flow in spirit but is tailored for adding new entries rather than editing existing ones.

// src/features/catalog/BookCard.tsx (excerpt)
<button
  onClick={onAdd}
  disabled={addMutation.isLoading || !user || isInShelf}
  className="bg-sky-600 hover:bg-sky-500 disabled:bg-slate-800 disabled:text-slate-400 disabled:border disabled:border-slate-600 disabled:opacity-80 disabled:cursor-not-allowed text-white text-sm px-3 py-1 rounded-md"
  aria-label={`Add ${title} to my shelf`}
>
  {isInShelf
    ? 'In shelf'
    : addMutation.isLoading
      ? 'Adding…'
      : !user
        ? 'Log in to add to my shelf'
        : 'Add to my shelf'}
</button>
  • The button dynamically changes its label based on state:
    • "In shelf" if the book is already added.
    • "Adding…" while the mutation is in flight.
    • "Log in to add to my shelf" if the user is not authenticated.
    • "Add to my shelf" in the normal case.
  • The disabled attribute also depends on these conditions, ensuring the button cannot be clicked unnecessarily.
  • Styling includes hover states, disabled states, and accessibility attributes (aria-label).

This ensures clear feedback to the user: they know when a book is already in their shelf, when an action is pending, and when they need to log in first. Together with the ProgressEditor, it completes the cycle of adding and managing progress for books in the application.

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