Skip to content

Lesson 21 Guide — Server State with TanStack Query (optional)

Goal: by the end of this lesson your Menu Items list fetches through TanStack Query v5 instead of useState + useEffect, the delete button refreshes the list by invalidating a cache entry rather than splicing an array, every read error routes through one handler, and you can see the cache itself in a devtools panel. You'll do it on Menu Items; the lab does Products in your PRS app.

The general pattern you're learning: the data on your screen belongs to a server you don't control, so it is always a cached copy that might already be wrong. Code that treats it as component state has to answer "when do I refetch?" by hand, over and over, in every component. A server-state library answers it once — you say what you want and what it's called, and the library owns when to go get it again.

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: a working TableServe React app through Lesson 12, and your TableServe Web API running. Sign in as an admin before you start — jordan.reyes / test1234 — because Lesson 11 hid the Menu Items nav link behind isAdmin, and several checks below have you navigate to it and back.

This lesson is TableServe only — don't retrofit anything else

This is optional material taught after the capstone. Two things stay exactly as you built them:

  • Your PRS capstone as submitted. The lab does have you migrate one PRS feature, but on a branch you never merge. Your main stays the app you turned in.
  • The team-project repository. It ships on the useState + useEffect pattern and stays there. A ticket is not an invitation to change how the whole app fetches.

Nothing in the capstone, the team project, or any earlier lesson depends on TanStack Query. It is a tool you're being shown, not a correction to what you wrote.


1. Two kinds of state, and only one of them is yours

Every useState you've written so far in this pass holds one of two very different things.

Client state is yours. Which modal is open. What's typed in the search box. Which tab is selected. You created it, you're the only one who changes it, and it is correct by definition — there is no "stale" version of "the Cancel modal is open."

Server state is a copy. menuItems in your Menu Items page is not the menu; it's a photograph of the menu taken at the moment your fetch resolved. The real menu lives in SQL Server, and it can change without telling you — a manager edits a price, a teammate deletes an item, you yourself add one in another tab. Your copy doesn't know.

Look at what Lesson 6 had you write to manage that copy:

src/menuItems/MenuItemsPage.tsx — what Lessons 4, 6 and 12 built
const [loading, setLoading] = useState(false);
const [menuItems, setMenuItems] = useState<IMenuItem[]>([]);

async function loadMenuItems() {
  setLoading(true);
  try {
    const data = await menuItemAPI.list();
    setMenuItems(data);
  } catch (error: any) {
    toast.error(error.message, { duration: 6000 });
  } finally {
    setLoading(false);
  }
}

useEffect(() => {
  loadMenuItems();
}, []);

(An illustration of code you already have — nothing to add.) Count what's in there: two pieces of state, a loading flag you have to remember to set and unset, a try/catch you copy into every list page, and an effect with an empty dependency array that means "fetch once, ever." Open this page, walk away for an hour, come back — you are looking at hour-old data and the app is perfectly happy about it.

And that's just reading. Deleting is worse. MenuItemCard calls the API, then calls onRemove, which reaches back into MenuItemsPage to splice the item out of an array — a second, hand-written copy of "what the server now contains," maintained separately from the first.

None of this is wrong. It's what React gives you. But notice that every one of those lines is about when and how to get data, not about menu items — and you will write all of it again for Staff, and again for Orders, and again for all five PRS entities.

TanStack Query is a cache with rules. You hand it a function that fetches, and a key to file the result under. It stores the result, hands the same result to every component that asks for that key, and refetches on triggers you configure instead of triggers you code.


2. ▶ Code along — install it and provide the client

Two packages. The second is a devtools panel that renders nothing in a production build.

npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools

The QueryClient is the cache — one per app, created outside any component. Inside one, it would be rebuilt on every render and take the whole cache with it; App re-renders every time someone signs in or out, so that isn't theoretical.

Module scope is what makes it safe, so the top of App.tsx would work. It gets its own file because section 6 grows it to about twenty lines of error handling, and App.tsx is busy enough already.

src/queryClient.ts
import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient();

Now make it available to the whole tree. QueryClientProvider is a Context provider — the same mechanism you built by hand for the signed-in Staff member in Lesson 11 — so it goes outside everything that will use it:

src/App.tsx
  import { Toaster } from "react-hot-toast";
+ import { QueryClientProvider } from "@tanstack/react-query";
+ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+ import { queryClient } from "./queryClient";

  ...

  function App() {
    const [staff, setStaff] = useState<IStaff | undefined>(getPersistedStaff());

    return (
+     <QueryClientProvider client={queryClient}>
        <StaffContext.Provider value={{ staff, setStaff }}>
          <Toaster />
          <Outlet />
        </StaffContext.Provider>
+       <ReactQueryDevtools initialIsOpen={false} />
+     </QueryClientProvider>
    );
  }

Everything inside <StaffContext.Provider> keeps its current indentation — it just moves one level in.

Save and check

  • Open any page — the app looks exactly as it did, because nothing uses the cache yet.
  • Look at the bottom corner of the window — a small TanStack logo button has appeared.
  • Click it — a panel opens listing no queries at all. That's correct; you haven't written one.

3. ▶ Code along — the list becomes one useQuery

useQuery needs exactly two things: a queryKey (what to file this under) and a queryFn (how to get it). It returns the cache entry's current state.

All of it comes out of MenuItemsPage — the fetching state, the loader, and the effect. The page's markup doesn't change at all:

src/menuItems/MenuItemsPage.tsx
- import { useEffect, useState } from "react";
+ import { useQuery } from "@tanstack/react-query";
  import { Link } from "react-router-dom";
- import { IMenuItem } from "./IMenuItem";
  import { menuItemAPI } from "./MenuItemAPI";
  import MenuItemCard from "./MenuItemCard";
  import MenuItemCardSkeleton from "./MenuItemCardSkeleton";
- import toast from "react-hot-toast";

  function MenuItemsPage() {
-   const [loading, setLoading] = useState(false);
-   const [menuItems, setMenuItems] = useState<IMenuItem[]>([]);
+   const { data: menuItems = [], isPending } = useQuery({
+     queryKey: ["menuItems"],
+     queryFn: menuItemAPI.list,
+   });
+
    const menuItemCardSkeletons = Array.from(Array(12), (_value, index) => (
      <MenuItemCardSkeleton key={index} />
    ));

-   async function loadMenuItems() {
-     setLoading(true);
-     try {
-       const data = await menuItemAPI.list();
-       setMenuItems(data);
-     } catch (error: any) {
-       toast.error(error.message, { duration: 6000 });
-     } finally {
-       setLoading(false);
-     }
-   }
-
-   useEffect(() => {
-     loadMenuItems();
-   }, []);
-
-   function removeMenuItem(deleted: IMenuItem) {
-     setMenuItems(menuItems.filter((menuItem) => menuItem.id !== deleted.id));
-   }
-
    return (
      // ... the heading and the Add Item link are unchanged
        <section className="list d-flex flex-row flex-wrap bg-light gap-5 p-4 rounded-4">
-         {loading && menuItemCardSkeletons}
+         {isPending && menuItemCardSkeletons}

          {menuItems.map((menuItem) => (
-           <MenuItemCard key={menuItem.id} menuItem={menuItem} onRemove={removeMenuItem} />
+           <MenuItemCard key={menuItem.id} menuItem={menuItem} />
          ))}
        </section>
      // ...
    );
  }

Both the IMenuItem and toast imports go too — nothing in the file references them once the state and the catch are gone.

Four things worth naming before you move on.

queryKey: ["menuItems"] is an array, always, even for one element. It's the cache's filing label, and it gets compared by value, not by reference.

queryFn: menuItemAPI.list — no parentheses. You are handing over the function, for the library to call when it decides to. Your MenuItemAPI.ts doesn't change at all; checkStatus and parseJSON still run exactly as you wrote them in Lesson 12, and the unit tests you wrote for them in Lesson 18 still pass. TanStack Query replaces your state management, not your fetching.

data: menuItems = [] is destructuring with a rename and a default, both from Lesson 1. data is undefined until the first fetch resolves, and .map() on undefined throws — so default it to an empty array.

isPending means there is no data in this cache entry yet. It replaces your loading flag, and unlike loading, you can't forget to turn it off.

Gone: useState twice, useEffect, loadMenuItems, the try/catch/finally, and the toast import. Twenty lines became four.

Your editor is flagging MenuItemCard — that's expected

MenuItemCard still declares a required onRemove prop, and you just stopped passing it, because the array it spliced is gone. That's a type error only — the page still renders. The one thing that genuinely breaks is clicking ⋮ Delete, which now calls undefined. Section 5 fixes both. Leave it for now.

Save and check

  • Ignore the editor error and open the Menu page — the skeletons flash, then all your menu items render.
  • Open the devtools panel — there is now one entry, ["menuItems"], marked fresh then stale.
  • Click it — the right pane shows the actual array of menu items sitting in the cache.

Not yet: the ⋮ Delete item is broken, and the error toast you had in Lesson 12 is gone — sections 5 and 6.


4. What the cache does when you aren't looking

This is the section that explains why the library is worth a dependency. Everything below is default behavior you just switched on — no code required.

A cache entry is fresh, then stale

Every entry has a staleTime, and it defaults to 0: data is considered out of date the instant it arrives. That sounds aggressive, and it is deliberate — "stale" doesn't mean "hidden" or "refetched immediately." Stale data renders normally. It only means "if a refetch trigger fires, this entry is eligible."

Fresh data is never refetched, no matter how many triggers fire.

The four triggers

A stale entry refetches when:

  1. A component mounts that uses the key (refetchOnMount)
  2. The window regains focus (refetchOnWindowFocus)
  3. The network reconnects (refetchOnReconnect)
  4. You invalidate it — section 5

Trigger 2 is the one to actually watch, because nothing you have ever written does it:

Try this — the demo that explains the whole library

  1. Open the Menu page in your browser.
  2. Leaving it open, go to Insomnia and PUT a menu item with a changed price. (Or change it in SSMS. Or just open a second browser tab and edit it there.)
  3. Click back into your Menu page tab and don't touch anything.

The price updates. You wrote no code for that. Open the Network tab first and do it again if you want to see the request fire the moment the tab regains focus.

Now try the other side of it:

src/queryClient.ts — temporarily, to see the effect
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30 * 1000, // 30 seconds
    },
  },
});

Repeat the experiment. Nothing happens on focus — the entry is still fresh, so no trigger applies. Wait thirty seconds, click away and back, and it updates.

That one number is the whole tuning story: how out of date is this data allowed to be? A menu that changes twice a day can sit at five minutes. A kitchen's order queue wants zero. Put it back to the default (delete the defaultOptions block) before you continue — the rest of this lesson assumes it.

gcTime is a different thing, and the names confuse everyone

staleTime — how long data is considered current while something is displaying it.

gcTime — how long an entry is kept after nothing is displaying it. Default five minutes. Navigate away from Menu Items and back within five minutes and the cached list renders instantly with no skeleton, while a refetch happens quietly behind it. That's the "stale-while-revalidate" behavior you get for free.

Try it: click Orders, then Menu. No skeleton the second time.


5. ▶ Code along — delete becomes a mutation, and the array splice disappears

useQuery is for reading. useMutation is for writing — POST, PUT, DELETE. It doesn't run on render; it runs when you call .mutate().

The important half is what happens after a successful write. Right now MenuItemCard tells its parent to splice an item out of an array. With a cache, you don't patch your copy — you throw it away and let the library refetch. That's invalidation.

src/menuItems/MenuItemCard.tsx
  import { menuItemAPI } from "./MenuItemAPI";
  import toast from "react-hot-toast";
+ import { useMutation, useQueryClient } from "@tanstack/react-query";

  interface IMenuItemCardProps {
    menuItem: IMenuItem;
-   onRemove: (menuItem: IMenuItem) => void;
  }

- function MenuItemCard({ menuItem, onRemove }: IMenuItemCardProps) {
+ function MenuItemCard({ menuItem }: IMenuItemCardProps) {
+   const queryClient = useQueryClient();
+
+   const deleteMenuItem = useMutation({
+     mutationFn: (id: number) => menuItemAPI.delete(id),
+     onSuccess: () => {
+       queryClient.invalidateQueries({ queryKey: ["menuItems"] });
+       toast.success("Successfully deleted.");
+     },
+   });
+
    return (

And the click handler, further down the same file. Lesson 12's try/catch goes with it, because section 6 gives every failed mutation one place to land:

src/menuItems/MenuItemCard.tsx
        <Dropdown.Menu>
          <Dropdown.Item as={Link} to={`/menuitems/edit/${menuItem.id}`}>Edit</Dropdown.Item>
-         <Dropdown.Item as="a" href="#" onClick={async (event) => {
+         <Dropdown.Item as="a" href="#" onClick={(event) => {
            event.preventDefault();
            if (confirm("Are you sure you want to delete this menu item?") && menuItem.id) {
-             try {
-               await menuItemAPI.delete(menuItem.id);
-               onRemove(menuItem);                     // update parent state
-               toast.success("Successfully deleted.");
-             } catch (error: any) {
-               toast.error(error.message, { duration: 6000 });
-             }
+             deleteMenuItem.mutate(menuItem.id);
            }
          }}>Delete</Dropdown.Item>
        </Dropdown.Menu>

async is gone because nothing is awaited any more. .mutate() returns void, not a promise — it starts the request and hands control straight back. Everything that used to run after the await now lives in onSuccess, and the failure path lives in the cache handler from section 6. There is a .mutateAsync() that does return a promise, but it rejects on failure, so you'd owe it a try/catch and would toast twice — reach for it only when a caller genuinely has to sequence something after the write.

That's the whole fix — dropping onRemove from the interface and the signature is what clears the type error from section 3, because MenuItemsPage already stopped passing it.

invalidateQueries matches by prefix. { queryKey: ["menuItems"] } marks every entry whose key starts with ["menuItems"] as stale — which will matter a great deal in Lesson 22, when the key grows a page number on the end. Anything currently on screen refetches immediately; anything cached but unmounted refetches next time it's shown.

Save and check

  • The editor error from section 3 is gone — no component declares or passes onRemove now.
  • Delete a menu item from the ⋮ menu — the card disappears and the success toast fires, as before.
  • Watch the Network tab while you do it — you'll see two requests: the DELETE, then a fresh GET /api/menuitems. That second one is the invalidation. Your list is now showing what the server actually has, not what you assumed it has.

6. ▶ Code along — one place for read errors, one for write errors

Section 3 deleted a try/catch. Break the app on purpose to see what that cost: stop your Web API and reload the Menu page. Skeletons for about seven seconds while the library retries, then an empty grid — no toast, no message, nothing that says the API is down.

In Lesson 12 you centralized translating errors in fetchUtilities.ts. What you didn't centralize was showing them — every list page had its own catch with its own toast.error. TanStack Query gives you the missing half: the cache can hold one handler for every failure that passes through it.

src/queryClient.ts
- import { QueryClient } from "@tanstack/react-query";
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
+ import toast from "react-hot-toast";
+
+ function showError(error: Error) {
+   toast.error(error.message, { duration: 6000 });
+ }

  export const queryClient = new QueryClient({
+   // Every failed query lands here — one place for read errors.
+   queryCache: new QueryCache({
+     onError: showError,
+   }),
+
+   // Every failed mutation lands here — one place for write errors.
+   mutationCache: new MutationCache({
+     onError: showError,
+   }),
  });

Four things to notice.

error.message is still yours. It's whatever checkStatus threw — "There was an error saving or retrieving data." from translateStatusToErrorMessage. Nothing about your Lesson 12 work is replaced; it's finally being displayed from one place.

Two caches, because reads and writes fail differently. A failed read leaves a page with no data. A failed write leaves a form the user still has open. They share a handler here only because TableServe wants the same toast for both.

The toast is slower than you expect, and that's the library working. A failed query is retried three times before it gives up, with the waits doubling each time — 1s, then 2s, then 4s. Four attempts, about seven seconds from the first failure to the toast. On a phone that dropped a bar of signal, most of those retries succeed and the user never learns anything went wrong; on your desk with the API stopped, you just wait. Don't turn it down to make the demo snappier — you'd be trading a real production behavior for seven seconds of your own time.

Writes are not retried at all. useMutation defaults to zero retries, and that asymmetry is deliberate: re-sending a failed GET is free, but re-sending a POST that actually reached the server can create the record twice. Reads are safe to repeat; writes aren't. So a failed save toasts immediately while a failed load takes seven seconds — same handler, different defaults underneath.

Try this — watch it lose, then rescue it

The retries are invisible until you go looking, so break something on purpose.

  1. Break the URL. In MenuItemAPI.ts, change /menuitems to /menuitemz and save. The API now answers 404, which is a response — so checkStatus throws, exactly as it would for a real failure.

  2. Open the devtools panel, click the ["menuItems"] row, then click Refetch. The Actions row in the details pane is the cleanest way to re-run one query without reloading the page.

  3. Watch two things at once. The status badge reads fetching and stays there for about seven seconds. In the Network tab, four requests appear — at roughly 0s, 1s, 3s, and 7s. Those gaps are the backoff doubling: 1s, then 2s, then 4s.

  4. Let it lose. At about seven seconds the badge flips back and the error toast appears. Notice what doesn't happen: the cards stay on screen. A failed refetch on an entry that already has data keeps showing the data it has — only a first load has nothing to fall back on.

  5. Now rescue it. Click Refetch again, and while it's still retrying, put the s back in the URL and save. The list refreshes and no toast ever appears — you repaired the "server" inside the window, so the query never ran out of attempts and the user never learned anything was wrong.

Step 4 and step 5 are the same code, the same failure, and two different user experiences. The only variable is whether the problem outlived the retries. That is the whole argument for keeping them.

(The details pane also has Trigger Loading and Trigger Error, which fake those states outright. Useful for styling a loading or error view; they skip the retrying you just watched.)

In v5 there is no onError on useQuery itself

v4 let you pass onError to each useQuery call, and most blog posts you'll find still show that. It was removed in v5, on the grounds that a cache entry shared by three components shouldn't fire three toasts. The cache-level handler above is the replacement, and it fires once per failure no matter how many components are watching. useMutation kept its own onError — a mutation belongs to one component, so there's no ambiguity.

Save and check

  • With the API still stopped, reload the Menu page and watch the Network tab — four failed requests appear, spaced further and further apart.
  • Keep waiting — after about seven seconds the skeletons clear and one error toast appears. Not four toasts: the handler fires once per failed query, not once per attempt.
  • Start the API again and reload — the list renders normally.
  • Now try a write against the stopped API — a delete from the ⋮ menu. The toast is immediate, because mutations don't retry.

7. Verifying in the browser

Everything at once, API running:

  1. Menu page loads — skeletons, then cards.
  2. Devtools panel shows ["menuItems"] with the array in it.
  3. Navigate to Orders and back — no skeleton the second time; the cached list paints immediately.
  4. Edit a price in Insomnia, click back into the tab — the card updates with no interaction.
  5. Delete an item — card goes, toast fires, and the Network tab shows DELETE followed by a fresh GET.
  6. Stop the API, reload — exactly one error toast.

If step 4 doesn't fire, check that you removed the temporary staleTime from section 4.


The General Pattern (what to take away)

Data that lives on a server is a cache, so manage it with a cache. Every awkward thing in the Lesson 6 pattern comes from pretending otherwise: the loading flag exists because you had to track the request; the empty dependency array exists because you had to pick a moment to fetch; the array splice after delete exists because you had to keep a second copy in sync. Name the data, say how to fetch it, and those three problems become someone else's.

Two ideas transfer to any server-state library, in any framework:

  • A key identifies data, not a request. Two components asking for ["menuItems"] share one entry and one network call. Change the key and you're asking about different data — which is the entire mechanism behind filtering and pagination in Lesson 22.
  • After a write, invalidate rather than patch. Patching your local copy encodes an assumption about what the server did. Invalidating asks. When the server does something you didn't predict — recalculates a total, applies a rule — only one of those two stays correct.

Build Steps

  1. npm install @tanstack/react-query and npm install -D @tanstack/react-query-devtools.
  2. Create src/queryClient.ts exporting a new QueryClient().
  3. In App.tsx, wrap the tree in <QueryClientProvider client={queryClient}> and add <ReactQueryDevtools initialIsOpen={false} /> inside it.
  4. In MenuItemsPage.tsx, replace the useState/useEffect/loadMenuItems/removeMenuItem block with one useQuery({ queryKey: ["menuItems"], queryFn: menuItemAPI.list }), and drop the now-unused IMenuItem and toast imports.
  5. Default data to [], and drive the skeletons off isPending.
  6. Run the focus-refetch experiment, then the staleTime: 30 * 1000 experiment, then remove the staleTime again.
  7. In MenuItemCard.tsx, add useQueryClient and a useMutation for delete whose onSuccess calls invalidateQueries({ queryKey: ["menuItems"] }) and shows the success toast.
  8. Change the ⋮ Delete handler to call deleteMenuItem.mutate(menuItem.id), and drop onRemove from the props interface and the function signature.
  9. In queryClient.ts, add a QueryCache and a MutationCache with a shared onError that toasts error.message. Leave the retry defaults alone, and watch the four attempts in the Network tab.
  10. Work through section 7's six verification steps.