Introduction: Why Edit Shelf Status?

Welcome back! In the last lessons, you learned how to fetch your reading shelf and update your reading progress. Now, let’s take the next step: editing the status of a book on your shelf.

Shelf status is a simple way to track where you are with each book. For example, you might want to mark a book as “Want to read,” “In progress,” or “Completed.” This helps you organize your reading and see your progress at a glance.

In this lesson, you will learn how to let users change the status of a book directly from their shelf. This is a key feature for any modern reading app, and it builds on what you’ve already learned about React state and optimistic UI updates.

How the StatusEditor Works

The StatusEditor component is responsible for letting users change the status of a book. It displays a set of buttons, one for each possible status. When you click a button, the status for that book is updated and the change is reflected immediately in the UI.

Here’s the main part of the StatusEditor component:

// src/features/shelf/StatusEditor.tsx
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ShelfItemDto, ShelfStatus } from "../../lib/types";
import { updateProgress } from "../../api/reading";
import { useAuth } from "../auth/AuthContext";

export default function StatusEditor({ item }: { item: ShelfItemDto }) {
  const { user } = useAuth();
  const qc = useQueryClient();
  const [selected, setSelected] = useState<ShelfStatus>(item.status);

  const mutation = useMutation({
    mutationFn: (status: ShelfStatus) =>
      updateProgress({
        userId: user!.sub,
        bookId: item.bookId,
        currentPage: item.currentPage,
        status,
      }),
    onMutate: async (status) => {
      // cancel running queries to avoid clobbering our optimistic update
      await qc.cancelQueries({ queryKey: ["shelf"] });
      // define the query key we are going to manipulate
      const key = ["shelf", { status: "", sortBy: "updatedAt", order: "desc" }];
      // take a snapshot of the current shelf data so we can roll back if needed
      const previous = qc.getQueryData<ShelfItemDto[]>(key);
      // immediately update the cache with the new status for this book
      qc.setQueryData<ShelfItemDto[]>(key, (old) =>
        old?.map((it) => (it.bookId === item.bookId ? { ...it, status } : it)) || []
      );
      // update local state to reflect the button highlight
      setSelected(status);
      return { previous, key };
    },
    onError: (_e, _vars, ctx) => {
      if (ctx?.previous && ctx.key) qc.setQueryData(ctx.key, ctx.previous);
      setSelected(item.status);
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["shelf"] });
    },
  });

  const Btn = ({ value, label }: { value: ShelfStatus; label: string }) => (
    <button
      type="button"
      onClick={() => mutation.mutate(value)}
      disabled={mutation.isLoading}
      className={
        `px-2 py-1 rounded-md text-xs border transition-colors ` +
        (selected === value
          ? "bg-sky-600 border-sky-500 text-white"
          : "bg-slate-700 border-slate-600 text-slate-200 hover:bg-slate-600") +
        (mutation.isLoading ? " opacity-80 cursor-wait" : "")
      }
      aria-pressed={selected === value}
    >
      {label}
    </button>
  );

  return (
    <div className="flex flex-wrap gap-2 items-center mt-2" aria-label="Update status">
      <Btn value="want-to-read" label="Want to read" />
      <Btn value="not-started" label="Not started" />
      <Btn value="in-progress" label="In progress" />
      <Btn value="completed" label="Completed" />
    </div>
  );
}
  • A local selected state tracks the currently highlighted status for immediate visual feedback.

  • useMutation is configured with updateProgress, sending both currentPage and the new status.

  • onMutate provides optimistic UI:

    • Cancels ongoing shelf queries.
    • Stores the previous shelf data so we can roll back if needed.
    • Updates the cache immediately so the UI reflects the change right away.
    • Updates local state with setSelected(status).
  • onError restores the previous state if something fails.

  • onSettled invalidates the shelf query, ensuring eventual consistency with the backend.

  • The qc variable is a query client instance provided by React Query. It acts as the gateway to the entire query cache: letting us cancel queries, read cached data (getQueryData), update cached data (setQueryData), and request fresh data (invalidateQueries). Without the query client, optimistic UI would not be possible, because components would only react to server responses instead of cache manipulations.

  • In onMutate, we first call qc.cancelQueries to stop any in-flight requests that might overwrite our optimistic changes. We then snapshot the current cache (getQueryData) and update it with the new status (setQueryData). This makes the UI respond instantly, before the network request finishes. Finally, we update local component state so the currently selected button is highlighted.

  • The Btn is a locally defined inner component inside StatusEditor. We do this because these buttons are tightly coupled to the logic and state of StatusEditor. Defining it inline avoids the overhead of a separate file, keeps all relevant code in one place, and makes it easy to reuse the same styles and behavior across multiple status values. Since the button’s only purpose is to render status-specific options and trigger mutations, a standalone component would add unnecessary indirection.

This combination of cache manipulation with the query client (qc) and a local inline Btn definition gives us a fast, responsive, and maintainable way to let users update shelf status in real time. This ensures that a click on a status button feels instant while still being reliable.

Deep Dive: How Queries and Mutations Work Together in Status Updates

By now, you’ve learned the basics of queries (for fetching data) and mutations (for changing data). In this project, editing shelf status combines both concepts:

  • Queries (useQuery) are responsible for fetching the shelf and keeping it cached.
  • Mutations (useMutation) are responsible for sending updates (like changing status) to the server.
  • The Query Client (qc) links them together: it cancels, updates, or refetches queries when a mutation runs.

The Role of Queries

The shelf view (MyShelfPage) uses useQuery to fetch shelf entries from the backend:

  • Each shelf item is displayed with its current status.
  • The shelf query is cached by React Query, keyed by ["shelf", { status, sortBy, order }].
  • Whenever filters or sorting change, React Query automatically refetches new data.

This means the shelf is always driven by server state, but still benefits from caching for performance.

The Role of Mutations

When you edit a book’s status in StatusEditor, a mutation runs:

  1. Send the change to the server via updateProgress.
  2. Optimistically update the cache with the new status.
  3. Rollback if the request fails.
  4. Invalidate the shelf query so a fresh fetch confirms the update.

This ensures that:

  • The UI feels instant (optimistic update).
  • The cache stays correct (rollback on error).
  • The server is always the source of truth (final refetch).
Query + Mutation Flow

Here’s how they work together step by step:

  1. The shelf page (useQuery) loads the current books and their statuses.
  2. The user clicks a status button.
  3. useMutation runs with the new status.
    • onMutate: cancels active queries, snapshots the old cache, updates the cache optimistically.
    • onError: restores the snapshot if the request fails.
    • onSettled: invalidates the shelf query so the UI syncs with the backend.
  4. The shelf query refetches, pulling in the authoritative state from the server.

Why This Matters

  • Fast feedback: Users see the new status immediately.
  • Error safety: If the server rejects the change, the app rolls back seamlessly.
  • Consistency: The final refetch guarantees the frontend and backend match.

🔑 Key Takeaways

  • Queries keep the shelf data fresh and cache results.
  • Mutations apply changes and manage optimistic updates.
  • The Query Client ties them together with cancel, set, and invalidate methods.
  • This combination makes the shelf status editor feel smooth, responsive, and reliable.
Updating Status: React State and Mutations

Let’s look more closely at how the status update works when you click a button.

const [selected, setSelected] = useState<ShelfStatus>(item.status);

const mutation = useMutation({
  mutationFn: (status: ShelfStatus) =>
    updateProgress({
      userId: user!.sub,
      bookId: item.bookId,
      currentPage: item.currentPage,
      status,
    }),
  onMutate: async (status) => {
    await qc.cancelQueries({ queryKey: ["shelf"] });
    const key = ["shelf", { status: "", sortBy: "updatedAt", order: "desc" }];
    const previous = qc.getQueryData<ShelfItemDto[]>(key);
    qc.setQueryData<ShelfItemDto[]>(key, (old) =>
      old?.map((it) =>
        it.bookId === item.bookId ? { ...it, status } : it
      ) || []
    );
    setSelected(status);
    return { previous, key };
  },
  // ...onError and onSettled handlers
});

Explanation:

  • When you click a status button, mutation.mutate(value) is called.
  • The onMutate function is used for an optimistic update. It updates the UI right away, before the server responds.
  • The selected status is updated in the UI, and the shelf data in the cache is also updated.
  • This makes the app feel fast and responsive.

Output:
When you click a new status, the button highlights immediately, even before the server confirms the change.

Handling Errors and Keeping Data Fresh

Sometimes, the update might fail (for example, if there’s a network problem). The StatusEditor handles this by rolling back to the previous state:

onError: (_e, _vars, ctx) => {
  if (ctx?.previous && ctx.key) qc.setQueryData(ctx.key, ctx.previous);
  setSelected(item.status);
},
onSettled: () => {
  qc.invalidateQueries({ queryKey: ["shelf"] });
},

Explanation:

  • If the update fails, onError restores the previous shelf data and resets the selected status.
  • After the mutation is done (whether it succeeded or failed), onSettled refreshes the shelf data from the server to make sure everything is up to date.

Output:
If there’s an error, the status button goes back to what it was before, so the user always sees the correct information.

Summary And Practice Preview

In this lesson, you learned how to let users edit the status of a book on their shelf. You saw how the StatusEditor component works, how optimistic updates make the UI feel fast, and how errors are handled to keep the data accurate.

You are now ready to practice these skills on your own.

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