Lesson 22 Guide — Mutations and Pagination (optional)¶
Goal: by the end of this lesson the Menu Item form loads its data with useQuery and saves
with useMutation, the Save button disables itself while a save is in flight, and the Menu page
pages through sixty menu items twelve at a time — without the grid ever going blank between
pages. You'll do it on Menu Items; the lab does Products in your PRS app.
The general pattern you're learning: a query key isn't a label you pick once — it's an input. Put a value in the key and the cache treats each value as its own entry, refetching when it changes and remembering what it already fetched. Filtering, sorting, searching and paging are all the same one idea, and this lesson builds it once.
How to use this guide. Sections headed ▶ Code along are ones you build into your project (each ends with a quick Save and check); unmarked sections are concept.
Prerequisite: Lesson 21 — your Menu Items list reads through
useQuery, delete invalidates, andqueryClient.tshas the two cache-level error handlers. Signed in as an admin again (jordan.reyes/test1234) so the Menu Items nav link is there.
Still TableServe only
Same fence as Lesson 21. Your PRS capstone stays as submitted; the lab's work happens on a branch you never merge. The team-project repository is not migrated.
1. The form reads two things, and one of them is conditional¶
MenuItemForm is shared by Create and Edit — Lesson 7's pattern. It needs:
- The categories, always, to fill the dropdown.
- The menu item, only when there's an
:idin the route.
In Lesson 7 you did both inside react-hook-form's async defaultValues:
const { register, handleSubmit, formState: { errors } } = useForm<IMenuItem>({
defaultValues: async () => {
await loadCategories(); // dropdown options, either mode
if (!id) return emptyMenuItem; // Create → blank record
return await menuItemAPI.find(Number(id)); // Edit → fetch the record
},
});
(An illustration of code you already have — nothing to add.) It works, and it hides something
worth seeing: those two fetches are sequenced by an await for no reason other than that
they're in the same function. Categories don't depend on the menu item. They should be two
independent cache entries, fetched in parallel, each reusable elsewhere.
Two new pieces make that possible.
enabled switches a query off. enabled: !!id means "on the Create page, don't run this at
all." The entry exists but stays idle.
react-hook-form's values prop is the counterpart to defaultValues. defaultValues is
read once, at mount — useless when your data arrives later. values is watched: hand it
a new object and the form resets to it. That's exactly the shape of query data, which is
undefined and then isn't.
2. ▶ Code along — the two queries, and why the form waits¶
import { categoryAPI } from "../categories/CategoryAPI";
- import { useState } from "react";
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
...
function MenuItemForm() {
const navigate = useNavigate();
+ const queryClient = useQueryClient();
const { id } = useParams<{ id: string }>();
- const [categories, setCategories] = useState<ICategory[]>([]);
-
- async function loadCategories() {
- setCategories(await categoryAPI.list());
- }
+
+ const { data: categories = [], isLoading: loadingCategories } = useQuery({
+ queryKey: ["categories"],
+ queryFn: categoryAPI.list,
+ });
+
+ const { data: menuItem, isLoading: loadingMenuItem } = useQuery({
+ queryKey: ["menuItems", Number(id)],
+ queryFn: () => menuItemAPI.find(Number(id)),
+ enabled: !!id,
+ });
const { register, handleSubmit, formState: { errors } } = useForm<IMenuItem>({
- defaultValues: async () => {
- await loadCategories(); // dropdown options, either mode
- if (!id) return emptyMenuItem; // Create → blank record
- return await menuItemAPI.find(Number(id)); // Edit → fetch the record
- },
+ values: menuItem ?? emptyMenuItem,
});
Now the part that isn't obvious. Add this just above the return, after every hook:
const save: SubmitHandler<IMenuItem> = (menuItem) => {
saveMenuItem.mutate(menuItem);
};
+ // The Category dropdown can only select a value once its options exist, so
+ // hold the form back until both queries have answered.
+ if (loadingCategories || loadingMenuItem) {
+ return (
+ <div className="spinner-border text-primary" role="status">
+ <span className="visually-hidden">Loading...</span>
+ </div>
+ );
+ }
+
return (
<form className="d-flex flex-wrap w-75 gap-2" onSubmit={handleSubmit(save)}>
(save and saveMenuItem arrive in section 3 — if you're typing straight through, add this
block after you've done that.)
Why the guard is not optional. Without it, the Edit page renders the <select> while the
categories query is still in flight — so it has one <option>, "Select Category…". When the
menu item arrives a moment later, react-hook-form sets the select's value to 1. A <select>
cannot hold a value that has no matching <option>, so the assignment is silently discarded and
the Category dropdown comes up blank on every edit. Lesson 7's await loadCategories()
prevented this by accident. Making the two fetches parallel exposed it.
That's the general shape of the bug: a form field whose valid values arrive on a separate request can't be populated until they have.
isLoading, not isPending — and this is the opposite of Lesson 21
In v5, isPending means "no data yet." A query with enabled: false never fetches, so
it has no data, so isPending stays true forever. Use it here and the Create page
shows a spinner that never stops.
isLoading means "no data yet and a request is actually in flight" — it's
isPending && isFetching. A disabled query isn't fetching, so isLoading is false and
Create renders immediately.
Rule of thumb: isPending for a query that always runs (Lesson 21's list), isLoading for
one that's conditional.
Save and check — after section 3, when the file compiles:
- Open Edit on any menu item — a brief spinner, then Name, Price and Category all filled in.
- Open Add Item — no spinner at all after the first visit, and an empty form.
- Devtools panel — two entries,
["categories"]and["menuItems", 1].
3. ▶ Code along — save becomes a mutation¶
Lesson 21's delete mutation had nothing to send. This one takes an argument: whatever
react-hook-form hands to save.
- const save: SubmitHandler<IMenuItem> = async (menuItem) => {
- try {
- delete menuItem.category; // send categoryId, not the embedded object
- if (!menuItem.id) await menuItemAPI.post(menuItem); // no id → Create
- else await menuItemAPI.put(menuItem); // has id → Edit
- } catch (error: any) {
- toast.error(error.message, { duration: 6000 });
- return;
- }
- toast.success("Successfully saved.");
- navigate("/menuitems");
- };
+ const saveMenuItem = useMutation({
+ mutationFn: (menuItem: IMenuItem) => {
+ delete menuItem.category; // send categoryId, not the embedded object
+ return menuItem.id ? menuItemAPI.put(menuItem) : menuItemAPI.post(menuItem);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["menuItems"] });
+ toast.success("Successfully saved.");
+ navigate("/menuitems");
+ },
+ });
+
+ const save: SubmitHandler<IMenuItem> = (menuItem) => {
+ saveMenuItem.mutate(menuItem);
+ };
The catch is gone because Lesson 21's MutationCache handler already toasts write failures —
one handler for every mutation in the app.
mutationFn takes one argument. That's not a limitation to work around; it's why
.mutate(menuItem) reads cleanly at the call site. When you need more, pass an object.
Now use the mutation's own state on the button. A useMutation result carries isPending,
isError, and isSuccess for that write:
<div className="d-flex justify-content-end w-100 mt-4">
<Link to="/menuitems" className="btn btn-outline-primary me-2">Cancel</Link>
- <button type="submit" className="btn btn-primary">
+ <button type="submit" className="btn btn-primary"
+ disabled={saveMenuItem.isPending}>
<svg className="bi pe-none me-2" width={16} height={16} fill="#FFFFFF">
<use xlinkHref={`${bootstrapIcons}#save`} />
</svg>
- Save menu item
+ {saveMenuItem.isPending ? "Saving..." : "Save menu item"}
</button>
</div>
That two-line change fixes a bug your app has had since Lesson 7: double submission. Nothing stopped an impatient user from clicking Save three times and creating three menu items. You never had the state to prevent it — now the mutation is holding it for you.
Save and check
- Edit a menu item, change the name, Save — success toast, back to the Menu page, and the list shows the new name.
- Watch the Network tab on that save — the
PUT, then a freshGETof the list. That second request isinvalidateQueriesdoing its job. - Create a new item — same behavior,
POSTinstead ofPUT. - Watch the button closely as you click Save — it reads "Saving…" and is disabled while the request is in flight.
4. "All the rows" was never going to hold¶
GET /api/menuitems returns every menu item. With fourteen that's fine. Real menus aren't
fourteen items, real product catalogues aren't fifty, and the failure isn't gradual — one day
the payload is 40 KB and the page is fine, and some later day it's 4 MB and the list takes six
seconds to paint on a tablet in a restaurant.
The fix is server-side paging: the client asks for a slice, and the server also reports how many rows there are in total so the client can draw the controls.
First, enough rows to make it real. Run
add-menu-items.sql
in SQL Server Management Studio. It's a top-up, not a re-seed: it adds 46 more menu items
(Ids 15–60) and touches nothing else, so you don't re-run populate-tableserve.sql first. It's
safe to run twice.
Check: the script prints MenuItemCount = 60 at the end, and your Menu page now shows sixty
cards in one very long grid. That's the problem, on screen.
5. ▶ Code along — a paged endpoint on the Web API¶
Back to C# for the only backend work in the React pass. Two pieces: a shape to return, and an action to return it.
namespace TableServe.Api.Models;
public class PagedResult<T> {
public List<T> Items { get; set; } = new();
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 12;
public int TotalCount { get; set; } = 0;
}
// GET: api/MenuItems
[HttpGet]
public async Task<ActionResult<IEnumerable<MenuItem>>> GetAll() {
return await _db.MenuItems
.Include(menuItem => menuItem.Category)
.ToListAsync();
}
+ // GET: api/MenuItems/paged?page=1&pageSize=12
+ [HttpGet("paged")]
+ public async Task<ActionResult<PagedResult<MenuItem>>> GetPaged(
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 12) {
+
+ var query = _db.MenuItems.Include(menuItem => menuItem.Category);
+
+ var totalCount = await query.CountAsync();
+
+ var items = await query
+ .OrderBy(menuItem => menuItem.Id)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToListAsync();
+
+ return new PagedResult<MenuItem> {
+ Items = items,
+ Page = page,
+ PageSize = pageSize,
+ TotalCount = totalCount
+ };
+ }
+
// GET: api/MenuItems/5
[HttpGet("{id}")]
Four decisions in there worth defending, because you'll be asked about all four.
It's a new action, not a change to GetAll. Everything else in your world — the Insomnia
collection, Order Items looking up menu items, your PRS app, three passes of teaching — expects
GET /api/menuitems to return a plain array. Adding a capability beside it breaks nothing.
Changing the existing contract to add a feature one page needs would break a lot.
.OrderBy() is required, not decoration. Skip/Take over an unordered set is undefined —
SQL Server makes no promise about row order without ORDER BY, so "page 2" could legitimately
repeat a row from page 1. Any time you page, you order first.
CountAsync() runs before Skip/Take. It's a separate SELECT COUNT(*) against the
whole table — that's what lets the client render "Page 3 of 5" without downloading 60 rows to
count them.
paged doesn't collide with {id}. ASP.NET Core prefers a literal route segment over a
parameter one, so /api/menuitems/paged matches GetPaged and never tries to parse "paged"
as an int.
Isn't PagedResult<T> a DTO? We don't use those.
Fair challenge — and Copilot will raise it too, which is exactly the triage exercise from the API pass's Lesson 7.
The convention this course holds is no DTOs for entities: we return MenuItem itself
rather than a hand-written MenuItemDto that copies its fields. PagedResult<T> doesn't
reshape the entity at all — Items is still a List<MenuItem>, Included category and
all. It's an envelope carrying the two numbers the client can't otherwise know. Keep it
generic and it serves every entity you ever page.
Save and check
- Run the API, then paste
http://localhost:5556/api/menuitems/paged?page=1&pageSize=12into your browser's address bar — JSON with anitemsarray of 12, and"totalCount": 60. - Change to
page=5— a different 12 items, sametotalCount. - Hit
http://localhost:5556/api/menuitems— still the plain array of all 60. Nothing you had before changed.
6. ▶ Code along — the page number lives in the query key¶
Mirror the C# shape in TypeScript. It's generic for the same reason:
export interface IPagedResult<T> {
items: T[];
page: number;
pageSize: number;
totalCount: number;
}
Add the call beside the existing list — which stays, untouched:
import { BASE_URL, checkStatus, parseJSON } from "../utility/fetchUtilities";
import { IMenuItem } from "./IMenuItem";
+ import { IPagedResult } from "../utility/IPagedResult";
const url = `${BASE_URL}/menuitems`;
export const menuItemAPI = {
list(): Promise<IMenuItem[]> {
return fetch(url).then(checkStatus).then(parseJSON);
},
+
+ listPaged(page: number, pageSize: number): Promise<IPagedResult<IMenuItem>> {
+ return fetch(`${url}/paged?page=${page}&pageSize=${pageSize}`)
+ .then(checkStatus)
+ .then(parseJSON);
+ },
+
find(id: number): Promise<IMenuItem> {
Now the list. The page number is ordinary useState — it's client state, and it belongs in
the component. What's new is that it also goes in the key:
- import { useQuery } from "@tanstack/react-query";
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
+ import { useState } from "react";
import { Link } from "react-router-dom";
import { menuItemAPI } from "./MenuItemAPI";
import MenuItemCard from "./MenuItemCard";
import MenuItemCardSkeleton from "./MenuItemCardSkeleton";
+ const PAGE_SIZE = 12;
+
function MenuItemsPage() {
+ const [page, setPage] = useState(1);
+
- const { data: menuItems = [], isPending } = useQuery({
- queryKey: ["menuItems"],
- queryFn: menuItemAPI.list,
- });
+ const { data, isPending, isPlaceholderData } = useQuery({
+ queryKey: ["menuItems", { page }],
+ queryFn: () => menuItemAPI.listPaged(page, PAGE_SIZE),
+ placeholderData: keepPreviousData,
+ });
+
+ const menuItems = data?.items ?? [];
+ const totalPages = Math.ceil((data?.totalCount ?? 0) / PAGE_SIZE);
- const menuItemCardSkeletons = Array.from(Array(12), (_value, index) => (
+ const menuItemCardSkeletons = Array.from(Array(PAGE_SIZE), (_value, index) => (
<MenuItemCardSkeleton key={index} />
));
Then the pager itself, as a sibling of the card grid — inside the page's outer <section>,
just after the </section> that closes the grid:
<section className="list d-flex flex-row flex-wrap bg-light gap-5 p-4 rounded-4">
{isPending && menuItemCardSkeletons}
{menuItems.map((menuItem) => (
<MenuItemCard key={menuItem.id} menuItem={menuItem} />
))}
</section>
+
+ {totalPages > 1 && (
+ <div className="d-flex justify-content-between align-items-center mt-4">
+ <button
+ className="btn btn-outline-primary"
+ onClick={() => setPage((currentPage) => currentPage - 1)}
+ disabled={page === 1}
+ >
+ Previous
+ </button>
+ <span className="text-secondary">
+ Page {page} of {totalPages}
+ </span>
+ <button
+ className="btn btn-outline-primary"
+ onClick={() => setPage((currentPage) => currentPage + 1)}
+ disabled={isPlaceholderData || page === totalPages}
+ >
+ Next
+ </button>
+ </div>
+ )}
</section>
);
}
queryKey: ["menuItems", { page }] is the whole trick. Page 1 and page 2 are different
cache entries, because the key differs. Change page and the key changes; a changed key is a
different question; a different question the cache hasn't seen gets fetched. You never call a
refetch function — there isn't one to call. This is the same mechanism you'd use for a
status filter (["orders", { status }]) or a search box.
queryFn: () => menuItemAPI.listPaged(page, PAGE_SIZE) now needs a wrapper arrow, because
it takes arguments. Lesson 21's queryFn: menuItemAPI.list didn't.
Prefix invalidation still works. MenuItemCard and MenuItemForm invalidate
{ queryKey: ["menuItems"] }, and every paged entry's key starts with ["menuItems"] — so
deleting an item on page 3 refetches page 3. That's why Lesson 21 made a point of prefix
matching.
The pager renders only when totalPages > 1, so nothing flashes during the first load —
totalPages is 0 until the first response lands.
Save and check
- The Menu page shows 12 cards and "Page 1 of 5", with Previous disabled.
- Click Next — the next 12 appear and it reads "Page 2 of 5".
- Open the devtools panel — a separate entry per page you've visited:
["menuItems",{"page":1}],["menuItems",{"page":2}]. - Click Previous — page 1 comes back instantly from cache.
7. keepPreviousData — the flicker you didn't have to fix¶
Throttle the network first — DevTools → Network → Slow 3G, the same trick Lesson 6 used to see the skeletons. Against localhost a page loads in a few milliseconds, which is too fast to see either of the two behaviors below.
Now take the placeholderData line out for a moment and click Next.
The grid empties. page changed, so the key changed, so you're now reading a cache entry
that has no data — isPending is true and data is undefined. Technically correct, and
horrible: the layout collapses and the page jumps every time you turn a page.
Put it back. Now, while page 3 loads, the cache serves page 2's data as a stand-in.
isPending stays false, the twelve cards from the previous page stay exactly where they were,
and they're swapped for the new twelve when the request lands. Nothing moves, nothing blanks.
The trade is that for a moment you are showing data that isn't what the controls say — the
pager reads "Page 3" while the cards are still page 2's. That's what isPlaceholderData is
for, and why it's on the Next button:
You can't click ahead again until the real data arrives. One flag, one problem solved.
In v5 this is placeholderData: keepPreviousData, imported
v4 had a boolean option, keepPreviousData: true. It was replaced in v5 by a function
you import from @tanstack/react-query and hand to placeholderData. Nearly every tutorial
and Copilot suggestion you'll find still shows the v4 boolean — it does nothing in v5, and
it fails silently. When something React Query-related doesn't work, check whether the
example is v4; it usually is.
8. Verifying in the browser¶
With the API running and 60 menu items seeded:
- Menu page — 12 cards, "Page 1 of 5", Previous disabled.
- Next, with the network throttled to Slow 3G — no blank grid at any point, and Next is disabled until the new page lands.
- Next to page 5 — Next disabled at the end.
- Previous back to page 1 — instant, from cache.
- Devtools panel — one entry per page visited.
- Delete a card on page 2 — the toast fires and that page refetches (Network tab:
DELETE, thenGET …/paged?page=2). - Edit a menu item and save — "Saving…" on the button, then the list shows the new value.
- Add Item, second visit — the form appears with no spinner and a filled Category dropdown.
The General Pattern (what to take away)¶
Put the inputs in the key. A query key that contains { page } turns "refetch when the page
changes" from something you write into something that simply follows from the key having
changed. Swap page for status, or a search term, or a sort column, and not one line of the
mechanism changes. The moment you find yourself writing a useEffect whose job is "refetch
when this variable changes," you're re-implementing what a key already does.
Two smaller ideas worth keeping:
- Pagination is a server capability first. No client-side cleverness fixes a 4 MB response;
the row count has to come down at the source, and the server has to tell you the total so the
client can draw controls.
Skip/TakeplusOrderByplus a count — all three, always. - Mutation state is real state, and you were doing without it.
isPendingon a mutation is what makes "disable Save while saving" a two-line change instead of anotheruseStateyou'd have to remember to reset in afinally.
Build Steps¶
- In
MenuItemForm.tsx, replace thecategoriesstate andloadCategorieswith auseQueryon["categories"]. - Add a second
useQueryon["menuItems", Number(id)]withenabled: !!id, destructuringisLoadingfrom both. - Replace
useForm's asyncdefaultValueswithvalues: menuItem ?? emptyMenuItem. - Replace the
savehandler with auseMutationthat PUTs or POSTs, invalidates["menuItems"], toasts, and navigates — then a one-linesavethat calls.mutate(). - Add the
if (loadingCategories || loadingMenuItem)spinner guard just above thereturn. - Disable the Save button on
saveMenuItem.isPendingand swap its label to "Saving…". - Run
add-menu-items.sqlin SSMS and confirm 60 menu items. - Add
Models/PagedResult.csand theGetPagedaction toMenuItemsController, then check/api/menuitems/paged?page=1&pageSize=12in the browser. - Add
src/utility/IPagedResult.tsand alistPagedmethod toMenuItemAPI.ts. - In
MenuItemsPage, addpagestate, change the key to["menuItems", { page }], calllistPagedfrom thequeryFn, addplaceholderData: keepPreviousData, and add the Previous/Next bar below the card grid. - Remove
placeholderDataonce to see the grid blank, then put it back. - Work through section 8's eight verification steps.