Server state with TanStack Query
Fetch, cache and refresh data from a server with useQuery, instead of hand-writing effects.
On Day 4 you fetched search suggestions inside an effect. It worked, but look at how much you had to manage yourself: a loading flag, an error flag, an ignore variable in the cleanup so old responses could not overwrite new ones. And it still had gaps. Type "dune", delete it, type it again, and the app fetches the same thing twice, because nothing remembers the earlier answer.
Data that lives on a server behaves differently from data that lives in your component. This lesson introduces a library built specifically for it, TanStack Query (often still called React Query). The react.dev docs themselves recommend reaching for a library like this, rather than raw effects, for data fetching in real apps.
Two kinds of state
Client state belongs to your app. Whether a modal is open, what is typed in an input, which filter is selected. Your code is the only source of truth, and it changes only when your code changes it. useState, useReducer and context handle it well.
Server state belongs to someone else's computer. A list of books from an API, a user profile, search results. Your app only holds a copy, and that copy can go out of date while the user is looking at it. Server state needs things client state never does:
- loading and error states, because the network is slow and sometimes fails;
- caching, so you do not ask for the same thing twice;
- refreshing, so stale copies get updated;
- de-duplicating, so ten components asking for the same data trigger one request.
TanStack Query does all of that for you.
Your first query
Install it with npm install @tanstack/react-query. Using it takes two steps:
- Create one
QueryClient(the notebook) and give it to your app with<QueryClientProvider>. That is a context provider, like the ones from earlier today. - In any component, call
useQuerywith a query key and a query function.
The sandbox uses a fake api.ts that waits 800ms and sometimes fails, so you can see every state. Click "Reload sandbox" (the refresh icon) a few times until you see the error.
Compare this with your Day 4 effect. There is no useState, no useEffect, no cleanup and no ignore flag. You describe what you want (the key and the function), and useQuery tells you where things stand:
isPendingistrueuntil the first data arrives.isErroristruewhen the query function threw or its promise rejected.errorholds what was thrown.dataholds the result. TypeScript knows its type from the query function's return type,BookResult[]. After the two early returns, TypeScript has narroweddataso it is no longerundefined.refetch()runs the query function again.
The query function must return a promise. In a real app it would look like this, which you can use in your local project:
async function fetchPopularBooks(): Promise<BookResult[]> {
const res = await fetch('https://example.com/api/popular');
if (!res.ok) throw new Error('Request failed: ' + res.status);
return res.json();
}fetch does not reject on HTTP errors like 404 or 500, so the res.ok check is what turns them into an error TanStack Query can see.
Query keys and the cache
The query key is an array that uniquely names a piece of data. TanStack Query stores each result in its cache under that key. If the data depends on a variable, the variable must be part of the key: ['books', subject], ['book', id], ['search', text].
Click through the subjects below, then click back to one you already visited. Watch the console.
The first visit to each subject shows "Loading". Returning to one shows the list instantly from the cache. You will also see "Refreshing in the background", and a new log line, because TanStack Query re-checks cached data when it is used again.
That background check happens because of staleness. By default, cached data is considered stale immediately (staleTime: 0), so it is shown right away but also refreshed. If you know data changes rarely, tell TanStack Query to trust it for a while:
useQuery({
queryKey: ['books', subject],
queryFn: () => fetchBooksBySubject(subject),
staleTime: 60 * 1000, // fresh for 1 minute: no background refetch in that time
});Separately, isFetching is true during any fetch, including background ones, while isPending is only true when there is no data at all yet. Use isPending for the big spinner and isFetching for a small "updating" hint.
Rebuilding SearchSuggestions
Now you can replace the Day 4 effect. The new SearchSuggestions uses two more options:
enabled: text.length >= 2keeps the query switched off until there is something worth searching for.placeholderData: keepPreviousDatakeeps showing the previous suggestions while the next ones load, so the list does not flicker on every keystroke.
Because the search text is in the key, a slow answer for "du" can never overwrite the answer for "dune". The race condition you guarded against by hand on Day 4 is handled for you.
Type "the" quickly, then delete a letter and type it again. The second time the results appear immediately, because ['search', 'the'] is already in the cache and staleTime says it is still fresh.
Deep diveChanging server data with useMutation
useQuery is for reading. For writing, such as saving a new book to a server, TanStack Query has useMutation. After a successful write you usually tell the cache that some queries are now out of date, so they refetch:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddBookButton({ book }: { book: NewBook }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: saveBook, // (book: NewBook) => Promise<Book>
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['books'] });
},
});
return (
<button onClick={() => mutation.mutate(book)} disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
);
}invalidateQueries({ queryKey: ['books'] }) marks every query whose key starts with ['books'] as stale, including ['books', 'fantasy'], and refetches the ones on screen. Your Reading List keeps its own books in a reducer for now, so you will not need mutations today. On Day 6 you will see React's built-in tools for forms that write data.
A book details query
Each button below selects a book id. Write the BookInfo component so it fetches details with fetchBookInfo(id) and shows the page count and first published year. Show "Loading..." on the first load of each id, and make sure revisiting an id is instant.
Show solution
import { useQuery } from '@tanstack/react-query';
import { fetchBookInfo } from './api';
export function BookInfo({ id }: { id: string }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ['book', id],
queryFn: () => fetchBookInfo(id),
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<article>
<h2>{data.title}</h2>
<p>
{data.pages} pages, first published in {data.firstPublished}
</p>
</article>
);
}The key ['book', id] gives each book its own cache entry. That is what makes revisiting instant. If you had used ['book'], every button would show the first book you loaded.
Which of these is server state?
A component shows the list from cache and a small Updating hint at the same time. Which flags are true?
Day 5 progress