Data Fetching in React: From fetch + Effects to TanStack Query, Suspense and use()
Fetching in an effect gives you no cache, deduplication, retries or invalidation; TanStack Query gathers them around the query key. We move the Bookstore catalog and the server-side cart to TanStack Query and measure a revisited search coming from the cache, an optimistic update rolled back on a 500, a 409 Problem Details shown to the user, Suspense's 300 ms reveal window, and a promise created during render sending 3,739 requests through use() in three seconds.
Version note: The examples in this post were built and run with React 19.3, TanStack Query 5.107, TypeScript 7.0, Vite 8.3 and Node.js 24; API calls went through the Vite dev proxy to a local copy of Bookstore.Api (ASP.NET Core 10), and network and screen records were collected with Playwright 1.65 in headless Chromium.
Predict First
In the React catalog of Bookstore, the online bookstore used as the shared example across this site's series, the user searches for "Dune", then clears the box, then types "Dune" again. The effect-based version from the sixth post sent three requests to the API for these three steps. In this post we move the search to TanStack Query and give the cache a 30-second staleTime. How many requests go out for the same three steps?
The second question is about the cart. The cart no longer lives in the browser but in the Bookstore API (/api/carts). The user clicks "Add" and the server answers 1.5 seconds later; when should "Cart: 2 item(s)" in the header change? What should happen if the server returns 500?
What Fetching in an Effect Lacks
Our useBookSearch works correctly: there is a debounce, the old request is cancelled, there is no race condition. But most of what we expect from a data layer is missing. There is no cache, so every search you come back to is requested again. If two components want the same data, two requests go out; both the header and the cart panel read the cart. Changing the page drops the old result and the screen goes blank for a moment. There is no retry on errors. There is no place that knows which screens must refresh when data changes (an order was placed, stock went down). If nested components fetch with their own effects one after another, the requests wait for each other, forming a waterfall.
Each of these can be solved by hand; the end result is a small cache library. The React team also presents fetching in an effect as a basic tool, but for real apps it recommends a framework's data layer or a client library with a cache. We chose TanStack Query; SWR and RTK Query are common alternatives doing the same job, and React Router's loaders join this picture in the next post.
Setup: QueryClient and a Typed API Client
npm install @tanstack/react-query@5.107.1
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from '@/features/auth/AuthProvider'
import { CartProvider } from '@/features/cart/CartProvider'
import { ThemeProvider } from '@/shared/theme/ThemeProvider'
// One cache for the whole app; data counts as fresh for 30 seconds
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
})
// Providers wrap the tree like root-level injectors: the nearest one above a component wins
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<CartProvider>
<App />
</CartProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
QueryClient is the app's single cache. TanStack Query's important defaults page states them plainly: cached data counts as "stale" immediately (staleTime: 0), stale data is refreshed in the background when a component mounts again, when the window regains focus or when the network reconnects, unused queries are removed from the cache after five minutes, and failing queries are retried three times. For the catalog, a 30-second staleTime is fresh enough.
We gathered the fetch calls in one function. On errors Bookstore.Api returns Problem Details as defined by RFC 9457; the client turns that into an ApiError:
// RFC 9457 Problem Details, as produced by ASP.NET Core's AddProblemDetails()
export type ProblemDetails = {
type?: string;
title?: string;
status?: number;
detail?: string;
errors?: Record<string, string[]>;
};
export class ApiError extends Error {
readonly status: number;
readonly problem: ProblemDetails | null;
constructor(status: number, problem: ProblemDetails | null) {
super(problem?.detail ?? problem?.title ?? `HTTP ${status}`);
this.name = 'ApiError';
this.status = status;
this.problem = problem;
}
}
// One place that knows how Bookstore.Api answers: JSON on success, Problem Details (or nothing) on error
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}) },
});
if (!response.ok) {
const isProblem = response.headers.get('Content-Type')?.includes('application/problem+json') ?? false;
throw new ApiError(response.status, isProblem ? ((await response.json()) as ProblemDetails) : null);
}
return (await response.json()) as T;
}
Typing the response with as T is an assumption, not validation; in the TypeScript series post "TypeScript ile Asenkron Kod: Promise, async ve fetch Cevabını Tiplemek" we explained why this is a contract and when runtime validation is needed. We will come back to that validation with zod in the forms post.
useQuery: Query Keys, staleTime and Pagination
import { keepPreviousData, queryOptions } from '@tanstack/react-query';
import type { BookDetails, BookSummary, PagedResult } from '@/features/catalog/types';
import { apiFetch } from '@/shared/api';
export const pageSize = 8;
// Every value the request depends on is part of the key
export function booksQuery(search: string, page: number) {
return queryOptions({
queryKey: ['books', { search, page }],
queryFn: ({ signal }) =>
apiFetch<PagedResult<BookSummary>>(
`/api/books?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`, { signal }),
// Keep showing the previous page while the next one loads
placeholderData: keepPreviousData,
});
}
export function bookQuery(isbn: string) {
return queryOptions({
queryKey: ['book', isbn],
queryFn: ({ signal }) => apiFetch<BookDetails>(`/api/books/${isbn}`, { signal }),
});
}
queryOptions gathers the key, the fetch function and the options in one object; the same definition can be used both in useQuery and when touching the cache by hand (prefetching, invalidation), and TypeScript ties the key to the data type. The query key is the identity of the cache entry: ['books', { search: 'Dune', page: 1 }] and ['books', { search: '', page: 2 }] are two separate entries. The function receives a signal; TanStack Query uses it to cancel a request whose result nobody looks at any more. The new catalog page:
export function CatalogPage() {
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [selectedIsbn, setSelectedIsbn] = useState<string | null>(null);
const { quantityOf, add } = useCart();
const searchInput = useRef<HTMLInputElement>(null);
useKeyboardShortcut('/', () => searchInput.current?.focus());
useKeyboardShortcut('Escape', () => setSelectedIsbn(null));
// Replaces the hand-written effect: caching, deduplication, cancellation and retries come with it
const search = useDebouncedValue(query.trim(), 300);
const { data, error, isFetching, isPlaceholderData } = useQuery(booksQuery(search, page));
const pageCount = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
function handleQueryChange(value: string) {
setQuery(value);
setPage(1); // a new search starts on the first page
}
return (
<>
<h1>Catalog</h1>
<div className="toolbar">
<SearchBox ref={searchInput} value={query} onChange={handleQueryChange} />
</div>
<p className="muted" role="status">
{error ? `Could not load books (${error.message})`
: !data ? 'Loading…'
: `${data.total} book(s) found${isFetching ? ' · updating…' : ''}`}
</p>
{data && (
<>
<div className={isPlaceholderData ? 'stale' : undefined}>
<BookList books={data.items} quantityOf={quantityOf} onAdd={add} onSelect={setSelectedIsbn} />
</div>
<nav className="pager" aria-label="Pages">
<button type="button" disabled={page === 1} onClick={() => setPage(page - 1)}>Previous</button>
<span>Page {page} of {pageCount}</span>
<button type="button" disabled={page >= pageCount} onClick={() => setPage(page + 1)}>Next</button>
</nav>
</>
)}
The effect from the sixth and seventh posts, the result state and the "which query does this result belong to" check are gone; a single useQuery call stands in their place. The debounce is still our job: TanStack Query does not decide when to send a request, it only manages the data for the given key. useDebouncedValue from the seventh post is used here unchanged. Since the API is paged, we removed the sort menu that had been there since the fourth post: sorting only the eight books on screen by price would be misleading, and the API has no sort parameter.
We ran the Predict First scenario on the dev server, followed by going back and forth between pages:
76 ms failed: GET /api/books?search=&page=1&pageSize=8 net::ERR_ABORTED
84 ms http: GET /api/books?search=&page=1&pageSize=8 200
107 ms status: 20 book(s) found | 1984, Anna Karenina, Brave New World, Crime and Punishment, Don Quixote, Dune, Emma, Foundation
-- type "Dune"
628 ms http: GET /api/books?search=Dune&page=1&pageSize=8 200
661 ms status: 1 book(s) found | Dune
-- clear the box, then type "Dune" again
1168 ms status: 20 book(s) found | 1984, Anna Karenina, Brave New World, Crime and Punishment, Don Quixote, Dune, Emma, Foundation
1677 ms status: 1 book(s) found | Dune
-- Next page, then Previous
2276 ms http: GET /api/books?search=&page=2&pageSize=8 200
2536 ms status: 20 book(s) found | Frankenstein, Great Expectations, Jane Eyre, Middlemarch, Moby-Dick, Pride and Prejudice, The Count of Monte Cristo, The Great Gatsby
2866 ms status: 20 book(s) found | 1984, Anna Karenina, Brave New World, Crime and Punishment, Don Quixote, Dune, Emma, Foundation
-- add Dune to the cart
2893 ms http: POST /api/carts 201
2897 ms http: PUT /api/carts/{cartId}/items/9780441172719 200
3252 ms header: Cart: 1 item(s) · ₺489.00
The answer: one request. When the box was cleared the first page came from the cache, and typing "Dune" again brought the Dune result from the cache too; going to the second page sent a request, coming back did not. The cancelled request at the very top comes from StrictMode: when the component's first mount was torn down, that query lost its only observer and TanStack Query cut the request that used the signal; the second mount started a new one. Thanks to placeholderData: keepPreviousData, the first page stayed on screen, dimmed, while the second page loaded, and the list never went blank.
On the Angular side httpResource does the same job: it reruns a request bound to a signal when that signal changes, cancels the old one and gives you value, isLoading and error signals. But a cache shared between components, deduplication, retries and invalidation after a mutation are outside its scope; in the Angular State Management series post "Server State'i Yönetmek: resource, httpResource, chain() ve Mutation Sonrası Invalidation" we built invalidation by hand. TanStack Query also has an Angular adapter that is still labelled experimental (@tanstack/angular-query-experimental); an option for teams that want to work with the same concepts in both worlds.
A Server-Side Cart: useMutation and Optimistic Updates
The cart's content is now server state. All that stays in the browser is which cart belongs to us, the cart id; we keep it in the Context from the seventh post, writing it to localStorage. The cart API is a thin wrapper:
import type { Cart, Order } from '@/features/cart/types';
import { apiFetch } from '@/shared/api';
export const cartKey = (cartId: string | null) => ['cart', cartId] as const;
export const cartApi = {
create: () => apiFetch<Cart>('/api/carts', { method: 'POST' }),
get: (cartId: string, signal?: AbortSignal) => apiFetch<Cart>(`/api/carts/${cartId}`, { signal }),
setQuantity: (cartId: string, isbn: string, quantity: number) =>
apiFetch<Cart>(`/api/carts/${cartId}/items/${isbn}`, { method: 'PUT', body: JSON.stringify({ quantity }) }),
remove: (cartId: string, isbn: string) => apiFetch<Cart>(`/api/carts/${cartId}/items/${isbn}`, { method: 'DELETE' }),
placeOrder: (cartId: string, customerName: string, customerEmail: string) =>
apiFetch<Order>('/api/orders', { method: 'POST', body: JSON.stringify({ cartId, customerName, customerEmail }) }),
};
The real work is in useCart:
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query';
import { cartApi, cartKey } from '@/features/cart/cartApi';
import { useCartId } from '@/features/cart/CartContext';
import { applyChange, type QuantityChange } from '@/features/cart/cartLines';
import type { Cart, CartLine } from '@/features/cart/types';
import { ApiError } from '@/shared/api';
const quantityMutationKey = ['cart', 'setQuantity'];
export function useCart() {
const { cartId, setCartId } = useCartId();
const queryClient = useQueryClient();
const { data: cart = null } = useQuery({
queryKey: cartKey(cartId),
queryFn: async ({ signal }) => {
try {
return await cartApi.get(cartId!, signal);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null; // the server forgot this cart (e.g. it was ordered)
}
throw error;
}
},
enabled: cartId !== null,
});
const quantityMutation = useMutation({
mutationKey: quantityMutationKey,
mutationFn: async (change: QuantityChange) => {
let id = cartId;
if (id === null || cart === null) {
const created = await cartApi.create();
// Seed the cache with the new (empty) cart so switching the key does not trigger a GET
queryClient.setQueryData(cartKey(created.id), created);
setCartId(created.id);
id = created.id;
}
return change.quantity > 0
? cartApi.setQuantity(id, change.isbn, change.quantity)
: cartApi.remove(id, change.isbn);
},
// Optimistic update: change the cached cart now, keep the old one for a rollback
onMutate: async (change) => {
if (cartId === null || cart === null) {
return { previous: undefined };
}
await queryClient.cancelQueries({ queryKey: cartKey(cartId) });
const previous = queryClient.getQueryData<Cart | null>(cartKey(cartId)) ?? undefined;
if (previous) {
queryClient.setQueryData(cartKey(cartId), applyChange(previous, change));
}
return { previous };
},
onError: (_error, _change, result) => {
if (result?.previous) {
queryClient.setQueryData(cartKey(result.previous.id), result.previous);
}
},
onSuccess: (saved) => queryClient.setQueryData(cartKey(saved.id), saved),
});
// Mutation state belongs to the hook instance that called mutate(); read the latest one from the cache instead
const latest = useMutationState({ filters: { mutationKey: quantityMutationKey }, select: (m) => m.state }).at(-1);
const lines = cart?.items ?? [];
const quantityOf = (isbn: string) => lines.find((line) => line.isbn === isbn)?.quantity ?? 0;
const setQuantity = (line: Omit<CartLine, 'lineTotal'>) => quantityMutation.mutate(line);
return {
cartId: cart?.id ?? null,
lines,
itemCount: lines.reduce((sum, line) => sum + line.quantity, 0),
total: cart?.total ?? 0,
quantityOf,
error: latest?.status === 'error' ? latest.error : null,
add: (book: { isbn: string; title: string; price: number }, amount: number) =>
setQuantity({ isbn: book.isbn, title: book.title, unitPrice: book.price, quantity: quantityOf(book.isbn) + amount }),
setQuantity,
remove: (line: CartLine) => setQuantity({ ...line, quantity: 0 }),
restore: setQuantity,
};
}
The read side is a useQuery. The header, the catalog and the cart panel each call useCart, but since they use the same key only one request goes out and all three read the same cache entry. The write side is a useMutation. On the first "Add", if there is no cart yet, it is created with POST /api/carts first; we write the new empty cart into the cache right away so that useQuery does not send a needless GET for the new key when the cart id changes. In the first attempt this line was missing, and the network log had a GET fetching the empty cart between the POST and the PUT.
The optimistic update has three steps: onMutate cancels reads in flight for that key (otherwise a late old response could overwrite our change), keeps the current cart and updates the cache with the expected new state. onError writes the saved cart back; onSuccess puts the real cart returned by the server into the cache. The function that computes the expected state is pure and independent of React:
import type { Cart, CartLine } from '@/features/cart/types';
export type QuantityChange = { isbn: string; title: string; unitPrice: number; quantity: number };
// Pure: our local guess of the cart after one quantity change (the server answer replaces it)
export function applyChange(cart: Cart, change: QuantityChange): Cart {
const others = cart.items.filter((line) => line.isbn !== change.isbn);
const items: CartLine[] = change.quantity > 0
? [...others, { ...change, lineTotal: change.unitPrice * change.quantity }].toSorted((a, b) => a.title.localeCompare(b.title))
: others;
return { ...cart, items, total: items.reduce((sum, line) => sum + line.lineTotal, 0) };
}
With Playwright we delayed every PUT by 1.5 seconds and failed the third one with 500:
148 ms http: POST /api/carts 201
1656 ms http: PUT /api/carts/{cartId}/items/9780441172719 200
1928 ms saved: Cart: 1 item(s) · ₺489.00
-- add Emma (PUT takes 1.5 s)
2009 ms at once: Cart: 2 item(s) · ₺738.90
3463 ms http: PUT /api/carts/{cartId}/items/9780141439587 200
3711 ms saved: Cart: 2 item(s) · ₺738.90
-- add Foundation, the server answers 500
3783 ms at once: Cart: 3 item(s) · ₺1,057.90
5234 ms http: PUT /api/carts/{cartId}/items/9780553293357 500
5486 ms after: Cart: 2 item(s) · ₺738.90
5487 ms alerts: ["Cart not updated: An error occurred while processing your request."]
When we added Emma the header changed within 50 ms; the server's answer arrived 1.5 seconds later and nothing changed on screen, because our guess was right. With Foundation the header first showed 3 items, went back to 2 when the 500 arrived, and an error message appeared in the cart panel. Our version is not optimistic on the very first add: when there is no cart yet it has to be created first, and the user sees the delay on the first add.
In the first attempt the error message never appeared:
162 ms http: POST /api/carts 201
1669 ms http: PUT /api/carts/{cartId}/items/9780441172719 200
1941 ms saved: Cart: 1 item(s) · ₺489.00
-- add Emma (PUT takes 1.5 s)
2016 ms at once: Cart: 2 item(s) · ₺738.90
3469 ms http: PUT /api/carts/{cartId}/items/9780141439587 200
3720 ms saved: Cart: 2 item(s) · ₺738.90
-- add Foundation, the server answers 500
3778 ms at once: Cart: 3 item(s) · ₺1,057.90
5230 ms http: PUT /api/carts/{cartId}/items/9780553293357 500
5480 ms after: Cart: 2 item(s) · ₺738.90
5484 ms alerts: []
The cart was rolled back, but there was no alert. The reason is the TanStack Query counterpart of the "a hook shares logic, not state" rule from the seventh post: useMutation's error belongs to the instance that called the hook. The button was clicked through the useCart of CatalogPage, which renders the book cards, while the error is shown by the useCart of CartPanel; they are separate mutation instances. The fix is to give the mutation a mutationKey and read the latest state from the cache with useMutationState.
Showing Problem Details Errors
Placing an order is also a mutation. On success the server deletes the cart and reserves stock; so we forget the cart id, remove the cart queries from the cache and invalidate the book details:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { cartApi } from '@/features/cart/cartApi';
import { useCartId } from '@/features/cart/CartContext';
export function usePlaceOrder() {
const { setCartId } = useCartId();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { cartId: string; name: string; email: string }) =>
cartApi.placeOrder(input.cartId, input.name, input.email),
onSuccess: () => {
// The server deleted the cart and reserved stock: forget the cart, refetch stock numbers
setCartId(null);
queryClient.removeQueries({ queryKey: ['cart'] });
queryClient.invalidateQueries({ queryKey: ['book'] });
},
});
}
In the local API copy War and Peace has 3 in stock. We signed in as Ada, put 5 into the cart and placed the order, then lowered the quantity to 3 and tried again:
http: GET /api/books/9781400079988 200 application/json
screen: 3 in stock
-- place order with 5 × War and Peace (3 in stock)
http: POST /api/orders 409 application/problem+json
body: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"Not enough stock for: 9781400079988.","traceId":"00-f5db24403da0a710cd58e7671123310e-2ef433914a5a486c-00"}
screen: Order failed: Not enough stock for: 9781400079988.
-- change the quantity to 3 and try again
http: POST /api/orders 201 application/json
screen: Order #1 placed · ₺2,067.00 | Cart: 0 item(s) · ₺0.00
-- open War and Peace again
http: GET /api/books/9781400079988 200 application/json
screen: Out of stock
The 409 response came with application/problem+json; since ApiError takes its message from the detail field, the user saw the server's sentence. On the second attempt the order was created and the cart was reset. The details panel had been opened before the order and cached as "3 in stock"; with a 30-second staleTime it would normally not be requested again. invalidateQueries made it stale, and when the panel opened again a new request went out: "Out of stock". Saying which queries a mutation made stale is your responsibility; TanStack Query does not guess it.
Showing the book's title instead of the ISBN, or writing the 409 next to a form field, would be a better experience. We will map the errors field of Problem Details to form fields in the tenth post, when we build the order form.
Suspense, Error Boundaries and use()
The details panel uses useSuspenseQuery instead of useQuery. The difference: if there is no data, the component does not render, it "suspends"; the nearest <Suspense> shows its fallback. If there is an error, the nearest error boundary catches it. That way the panel itself only describes the case where the data has arrived:
export function BookDetailsPanel({ isbn, onClose }: BookDetailsPanelProps) {
// Suspends until the details are in the cache; errors go to the nearest error boundary
const { data: book } = useSuspenseQuery(bookQuery(isbn));
// Belongs to one book; the parent resets it with a key
{selectedIsbn && (
// key: another book gets a fresh boundary, a fresh panel and an empty gift note
<ErrorBoundary key={selectedIsbn} fallback={(e) => <p className="error" role="alert">Could not load details ({e.message})</p>}>
<Suspense fallback={<p className="muted">Loading details…</p>}>
<BookDetailsPanel isbn={selectedIsbn} onClose={() => setSelectedIsbn(null)} />
</Suspense>
</ErrorBoundary>
)}
An error boundary in React is still a class component; getDerivedStateFromError has no hook counterpart:
import { Component, type ErrorInfo, type ReactNode } from 'react';
type ErrorBoundaryProps = {
fallback: (error: Error) => ReactNode;
children: ReactNode;
};
type ErrorBoundaryState = { error: Error | null };
// Error boundaries are still class components: there is no hook for getDerivedStateFromError
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[boundary]', error.message, info.componentStack?.trim().split('\n')[0]);
}
render() {
return this.state.error ? this.props.fallback(this.state.error) : this.props.children;
}
}
With Playwright we turned Emma's details request into a 500 every time:
-- click "Dune"
13 ms http: GET /api/books/9780441172719 200
4 ms screen: Loading details…
306 ms screen: Dune | 25 in stock
-- click "Emma" (its details answer 500)
52 ms http: GET /api/books/9780141439587 500
1056 ms http: GET /api/books/9780141439587 500
3060 ms http: GET /api/books/9780141439587 500
7062 ms http: GET /api/books/9780141439587 500
7065 ms console: [boundary] An error occurred while processing your request. at BookDetailsPanel (http://127.0.0.1:52142/src/features/catalog/BookDetailsPanel.tsx?t=1794707205566:21:36)
7350 ms screen: Could not load details (An error occurred while processing your request.)
-- click "Dune" again
20 ms screen: Dune
For Dune the request returned in 13 ms, but the panel appeared at 306 ms. This is not slowness: according to react.dev, React reveals suspended content at most once every 300 ms, so that boundaries ready within the same window are shown together. For Emma the error reached the screen after 7 seconds: TanStack Query's default three retries sent four requests with waits of 1, 2 and 4 seconds. That is far too long for a details panel; in production it makes sense to reduce retry per query or not to retry 4xx errors at all. Thanks to the key on the error boundary, clicking Dune again created a new boundary and Dune came from the cache instantly. Everything else on the page kept working during the error; in the seventh post, with no boundary, the whole tree went down.
React 19's use(promise) is the low-level API of the same Suspense mechanism: it reads a promise and suspends while it is pending. The tempting use is creating the promise during render:
export function BookDetailsPanel({ isbn, onClose }: BookDetailsPanelProps) {
// WRONG: a new promise on every render
const book = use(apiFetch<BookDetails>(`/api/books/${isbn}`));
requests for /api/books/{isbn} in 3 s: 3739
panel on screen: false
3,739 requests went out in three seconds and the panel never opened. The component suspends, React renders it again when the promise resolves, the render creates a new fetch and a new promise, and the component suspends again. react.dev says so plainly: the promise passed to use must stay the same instance across renders, so it must either come from a cache or be created outside render. The version that creates the promise in the click handler and passes it down as a prop:
const [details, setDetails] = useState<Promise<BookDetails> | null>(null);
function handleSelect(isbn: string) {
setSelectedIsbn(isbn);
setDetails(apiFetch<BookDetails>(`/api/books/${isbn}`)); // created in an event, not during render
}
// The promise was created once, in the click handler; use() only reads it
const book = use(details);
requests for /api/books/{isbn} in 3 s: 1
panel on screen: true
It works, but there is no cache, no cancellation and no retry; if you want those, you are back to a library. The real place for use(promise) is where a framework or library hands you the promise: promises passed from a Server Component to the client (fifteenth post), router loaders and Suspense-enabled data libraries.
In Production
staleTime is a product decision. Thirty seconds is reasonable for the catalog; fields like stock and price may need less, and data that almost never changes, like the category list, much more. The default 0 means a background request on every mount and every window focus; if you see unexplained requests in the network tab, look here first.
Separate retries by error. Retrying makes sense for a dropped connection and 503, not for 404 and 409, where it only makes the user wait. The retry option also accepts a function; looking at ApiError's status to decide is what it is for.
Use optimistic updates for operations that are reversible and predictable. Adding to the cart is a good candidate: the user expects instant feedback and errors are rare. Placing an order is a bad one: stock, payment and validation are on the server; saying "your order has been placed" and taking it back a few seconds later misleads the user. We deliberately locked the order button with isPending.
TanStack Query's Devtools package (@tanstack/react-query-devtools) shows the keys in the cache, their state and when they become stale; keeping it open in development catches query key mistakes very early.
Common Pitfalls
Leaving parameters out of the query key. We typed "Dune" with a version that drops search and page from the key:
return queryOptions({
queryKey: ['books'], // WRONG: search and page are not in the key
request: ?search=&page=1&pageSize=8
request: ?search=&page=1&pageSize=8
input: Dune | status: 20 book(s) found | first books: 1984, Anna Karenina, Brave New World
The box says "Dune", the screen shows 20 books and no new request was sent: the key did not change and the data is fresh, so TanStack Query has nothing to do. The rule is the same as the dependency array of an effect: every value the fetch function uses must be in the key. The exhaustive-deps rule of TanStack's ESLint plugin checks this; Oxlint has no counterpart.
Copying server state into other state. useEffect(() => setBooks(data), [data]), or writing a query result into a Context or a global store, creates a second copy next to the cache; invalidation updates one and the other stays old. Let every component that needs the data call the same query; only one request goes out anyway.
Looking for mutation state on the wrong instance: as we saw above, isPending and error belong to the hook that called mutate. If you show them elsewhere, use mutationKey and useMutationState.
Passing a promise created during render to use(): every render means a new request. The promise either comes from a cache or is created outside render.
A Small Experiment
★☆☆ Set retry for the details query so that it never retries when ApiError's status is between 400 and 499, and retries once for other errors. How many seconds until the error appears in the Emma scenario?
★★☆ When the user hovers over a book card (onMouseEnter), prefetch that book's details with queryClient.prefetchQuery(bookQuery(isbn)). Is the "Loading details…" fallback still visible when you click? What changed in the network log?
★★★ Bring the "Clear cart" button back to the cart panel: write a mutation that sends a DELETE for every line and shows them all as a single optimistic update. If one of the requests fails, what state should the cart return to? How do you make the cache consistent with the last cart the server returned?
We moved data fetching to a cached layer; the catalog, the details and the cart still sit on one page. In the next post we split them into separate URLs with React Router 8 and look at nested layouts, URL parameters, loading data with a loader before entering a route, and code splitting with lazy routes.