Skip to content
React Course1.0.0 beta
CSEN
React Course1.0.0 beta
CSEN
🏠 Home🧭 Where to start?🔁 Review📄 Cheat sheets📰 What's newℹ️ About the course
0. JavaScript0/22

Basics

  • 1. A quick review of the basics0/3
  • 2. State and events (useState)0/3

Hooks in depth

  • 3. Effects (useEffect)0/3
  • 4. Refs (useRef)0/3
  • 5. More complex state (useReducer)0/3
  • 6. Context0/3
  • 7. Custom hooks0/3

Forms

  • 8. Forms and React 19 Actions0/3

Performance

  • 9. Performance and rendering0/3

Patterns

  • 10. Component design patterns0/3

Ecosystem

  • 11. Where the type goes (TypeScript syntax)0/3
  • 12. TypeScript with React0/3
  • 13. Routing (React Router)0/3
  • 14. Data fetching0/3
  • 15. Application state management0/3

Project

  • 16. Final project: Kanban0/2

Summary

  • 17. Summary: the principles of React

Your account

Privacy

Lesson 14 · Ecosystem

Data fetching

Why fetch in useEffect isn’t enough, TanStack Query, Suspense and use().

Loading lesson…

💬 Found a mistake, something unclear, or have an idea? Let me know.
← Routing (React Router)Application state management →

Before we start: a copied price list

Imagine an office where five people need to know a supplier's current prices. Each of them calls on their own and copies the price list into their notepad. The supplier gets five identical phone calls. A week later the supplier changes the prices – and the office has five notepads with old prices that nobody knows are old.

That's the essence of server data: it isn't yours. You only have a copy of it, which can go stale at any time. Local state (an open menu, the text in a field) is your own notebook – you know exactly what's in it. Server data is a copied price list. So it needs things local state doesn't:

  • One copy for everyone – when five components ask, there's one call (deduplication) and the result is shared (a cache).
  • Knowing when the copy is stale – and refreshing it (after a while, after returning to the window, after a change).
  • Crossing out the copy after a change – when you change something at the supplier yourself, the old copy is no longer valid.
  • Handling races between responses, retries on errors, paging…

That's why a better office has a noticeboard with a keeper. Whoever needs the price list looks at the noticeboard. The keeper calls the supplier only once, knows how old each piece of information is, and calls again when needed. In React, that keeper is the TanStack Query library.

ℹ️ What's in this lesson
  1. Why loading data by hand in an effect isn't enough.
  2. useQuery: the noticeboard keeper.
  3. useMutation and invalidation: changes on the server.
  4. Suspense and use(): loading described declaratively.

1. Why fetching in an effect isn't enough

From the lesson on effects you know how to load a user like this:

function UserName({ id }: { id: number }) {
const [user, setUser] = useState<User | null>(null)
useEffect(() => {
let ignore = false
fetchUser(id).then((u) => !ignore && setUser(u))
return () => { ignore = true }
}, [id])
return <span>{user ? user.name : 'Loading…'}</span>
}

It works, but each component is that person with their own notepad:

  • When UserName is on the page three times with the same id, three identical requests go out.
  • When you hide the component and show it again, the copy is thrown away – "Loading…" again and another request.
  • When the data changes on the server, nobody finds out.
  • And we haven't even dealt with errors, retries or paging.
Demo

useEffect vs. useQuery

What you see: On the left, three components that load the same user with useEffect. On the right, three that load it with useQuery. At the bottom, counters of real requests to the server.

Try it:

  1. Once loaded, click Refresh the counters. On the left it's 6 (three components, and in development mode every effect runs twice), on the right 1 – the keeper called once and handed the result to everyone.
  2. Click Unmount the components and then Mount again. On the left "Loading…" shows again, on the right the data is there at once – it came from the noticeboard.
  3. Refresh the counters. On the left more requests were added, on the right none (the data is still considered fresh for 10 s).

The takeaway: Loading by hand has no shared memory. useQuery shares one copy, merges identical requests and keeps the data even for components that appear later.

Loading the interactive part…

2. useQuery: the noticeboard keeper

// main.tsx – once for the whole app: we put up the noticeboard
const queryClient = new QueryClient()
<QueryClientProvider client={queryClient}><App /></QueryClientProvider>
// anywhere in the app
function UserName({ id }: { id: number }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ['user', id], // the label: exactly what I want
queryFn: () => fetchUser(id), // how to get it when it isn't on the noticeboard
staleTime: 60_000, // the information is fresh for a minute
})
if (isPending) return <span>Loading…</span>
if (isError) return <span>{error.message}</span>
return <span>{data.name}</span>
}

What happens when three UserNames with id = 2 appear on the page:

  1. The first asks the keeper for the label ['user', 2]. There's nothing on the noticeboard → the keeper calls queryFn. The component is in the isPending state.
  2. The second and third ask for the same label. The keeper knows a call is already in progress – it doesn't send a new request, it just notes them down.
  3. The reply arrives → the keeper pins it on the noticeboard and lets all three know. All three show the name.
  4. Five minutes later a fourth appears. The information is on the noticeboard, so it gets it immediately. Because it's older than staleTime, the keeper also refreshes it in the background.
❓ What does const { data: user } = useQuery(…) mean?

Destructuring with renaming: "take the data property and store it in a variable called user". useQuery always returns the data under the name data. When you have two queries in a component, both would be called data, so you rename them:

const { data: user } = useQuery({ queryKey: ['user', id], … })
const { data: posts } = useQuery({ queryKey: ['posts', id], … })

It's that colon in destructuring from the lesson "Where the type goes": here it doesn't mean a type, but a new name.

queryKey: the label on a folder

The keeper looks up data on the noticeboard by its label. So it must contain everything the query depends on. The fruit price list, page 2 is a different folder from the vegetable price list, page 1:

useQuery({
queryKey: ['products', { category, page }], // ✅ a change of category or page = a different folder
queryFn: () => fetchProducts({ category, page }),
})
useQuery({
queryKey: ['products'], // ❌ all the pages would share one folder
queryFn: () => fetchProducts({ category, page }),
})
TermMeaning
queryKeyThe label on the folder. Contains everything queryFn depends on.
staleTimeHow long the information is fresh (default 0 → refreshed on every mount or return to the window).
gcTimeHow long the noticeboard keeps data nobody uses (default 5 min).
isPendingWe don’t have any data yet (the first load).
isFetchingA request is running right now – even in the background when we already have data.
placeholderDataWhat to show before the data arrives – e.g. keepPreviousData when paging.

3. Mutations and invalidation: changes on the server

When you call the supplier yourself and order a change, you know the copy of the price list on the noticeboard is no longer valid. You cross it out, and the keeper gets a new one. That's exactly what the pair useMutation + invalidateQueries does:

function NewPost() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createPost, // a change on the server
onSuccess: () => {
// cross out all the folders starting with 'posts'
return queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
return (
<button onClick={() => mutation.mutate('A new post')} disabled={mutation.isPending}>
{mutation.isPending ? 'Saving…' : 'Add'}
</button>
)
}
  1. mutation.mutate(…) calls createPost. While waiting, isPending is true.
  2. After success, invalidateQueries marks the ['posts', …] folders as stale – all the pages of the list at once.
  3. The keeper reloads the ones currently on screen right away. The others only when someone needs them.
Demo

Paging + adding a post

What you see: At the top, a form for a new post (a mutation), at the bottom a paged list (a query with ['posts', { page }]). While data is being refreshed, "🔄 updating…" shows.

Try it:

  1. Click Next →. The old page stays visible (faded) until the new one arrives – thanks to keepPreviousData nothing flashes.
  2. Go back to the first page. It's there immediately – the folder is already pinned on the noticeboard.
  3. Add a post. After saving, the list refreshes by itself and the new post appears – the mutation crossed out the posts folders.
  4. Try adding a post with the word "error". The server rejects it and a message shows under the form.

The takeaway: Queries read from the noticeboard, mutations change the server and cross out old copies. The keeper takes care of the rest.

Loading the interactive part…

📍 Where to use it
Use TanStack Query (or SWR, RTK Query, Apollo for GraphQL) in every app that talks to an API. It's the single best improvement you can give a typical React app – and most of the "global state" disappears with it.

4. Suspense and use(): a waiting room with a sign

In a restaurant every table can report "I don't have my soup yet", "I don't have my main course yet"… Or the whole section gets a sign saying "being prepared, please wait" and the dishes are brought when they're ready. The second way is clearer – the waiter handles the waiting in one place.

Instead of if (isPending) in every component, you can describe the waiting once, one level up: wrap part of the tree in <Suspense fallback>. The components inside then simply "have" the data.

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // here we already have the data – no isPending, no undefined
return <h2>{user.name}</h2>
}
<ErrorBoundary fallback={<p>User not found</p>}>
<Suspense fallback={<p>Being prepared…</p>}>
<UserProfile userPromise={getUser(id)} />
</Suspense>
</ErrorBoundary>
  1. use(userPromise) checks whether the Promise is done. If not, the component says "still waiting" and React shows the nearest fallback.
  2. When the Promise resolves, React renders the component again – and this time use returns the data.
  3. When the Promise fails, the nearest error boundary from the previous lesson catches the error.
Demo

use(promise) + Suspense + Error Boundary

What you see: Buttons #1–#5 and the profile of the selected user. The profile uses use(promise), the waiting is handled by Suspense, errors by an error boundary. Switching is wrapped in startTransition.

Try it:

  1. On the first load you'll see "⏳ Suspense fallback…" – the waiting room with a sign.
  2. Click #2. The old profile stays visible (faded) until the new one arrives – the transition from the lesson on performance keeps the old content instead of the sign.
  3. Go back to #1. It's there at once – the Promise is stored in the cache.
  4. Click #5. It doesn't exist, the Promise fails and an error from the error boundary shows instead of the profile.

The takeaway: The component with the data handles neither waiting nor errors. Suspense describes the waiting, the error boundary the errors – one level up and in one place.

Loading the interactive part…

ApproachWhen
useQueryThe default choice. You handle waiting and errors in the component.
useSuspenseQueryThe same with TanStack Query, but Suspense handles the waiting and an error boundary the errors. data is never undefined.
use(promise)The Promise comes from outside (a router loader, a Server Component, your own cache).
lazy(() => import())Lazy loading of a component’s code – also through Suspense.
⚠️ Watch out
use(fetchUser(id)) directly in a component doesn't work – every render creates a new Promise, the component waits for a new one and a new one, and loops forever. The Promise must be stored outside render (a cache, a loader, a library).
✨ Waterfalls
In a restaurant you can first order a drink, wait for it to arrive, and only then order food. Slow. It's just as slow when a parent waits for its data and only then renders a child, which starts loading its own. The solution: order everything at once and as early as possible – a router loader, prefetchQuery, useQueries.

Check yourself

Quiz A user adds a task through useMutation. How do you make sure the task list updates?
Quiz What goes into the queryKey for the query fetchProducts({ category, page })?
Quiz Three components call useQuery with the same queryKey at the same moment. How many requests go to the server?

Warm-up

Challenge

Your first useQuery

Warm-up

Load the product list with TanStack Query. The loading state and the list are finished.

Task (the same text is in the comment at the top of the file)
CHALLENGE: Your first useQuery

ProductNames doesn't load any data yet – the data is empty.

1. Replace the two TODO lines with one call:
     const { data, isPending } = useQuery({ queryKey: ['products'], queryFn: () => fetchProducts() })

The loading state and the list are already written.

Loading the interactive part…

Challenges

The first challenge is the move from section 1 to section 2: rewrite loading from an effect to useQuery and add prefetching. The second extends section 3 with an optimistic change right on the noticeboard – with a backup in case the server rejects the change.

❓ What are queryOptions and prefetchQuery (the From useEffect to useQuery challenge)?

prefetchQuery tells the keeper "start getting this now, even though nobody is showing it yet". Typically on hovering over a link: by the time the user clicks, the data is already on the noticeboard.

For useQuery and prefetchQuery to talk about the same folder, they must get the same queryKey and queryFn. queryOptions is a helper that wraps these options into one object. You write them once and use them everywhere:

const userQuery = (id: number) =>
queryOptions({ queryKey: ['user', id], queryFn: () => fetchUser(id), staleTime: 30_000 })
useQuery(userQuery(id)) // in a component
queryClient.prefetchQuery(userQuery(id)) // on hover
queryClient.invalidateQueries({ queryKey: userQuery(id).queryKey })
❓ How does an optimistic change in the cache work (the Optimistic likes challenge)?

The principle is the same as with useOptimistic in the lesson on forms: show the change right away, and if the server fails, revert it. Only you don't change the component's state, but the copy on the noticeboard. useMutation offers three "hooks" in time for that:

useMutation({
mutationFn: (id: number) => likePost(id),
// 1) BEFORE sending: stop any loading in progress, back up and change the noticeboard
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey }) // so an old refetch doesn't overwrite it
const previous = queryClient.getQueryData<PostsPage>(queryKey) // the backup
queryClient.setQueryData<PostsPage>(queryKey, (old) => …) // +1 like right away
return { previous } // → onError gets it as context
},
// 2) ON AN ERROR: restore the backup
onError: (err, id, context) => {
queryClient.setQueryData(queryKey, context?.previous)
},
// 3) ALWAYS AT THE END: cross out the copy so it matches the server
onSettled: () => queryClient.invalidateQueries({ queryKey }),
})

getQueryData reads what's on the noticeboard. setQueryData writes something else there (immutably, like state). Whatever onMutate returns, the other hooks get as context – that's how the backup gets to onError.

Challenge

From useEffect to useQuery + prefetch

Medium Bonus challenge

You'll see how much code disappears – and how many features you gain.

🔒 Bonus challenge

Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.

How we handle your data
Challenge

Optimistic likes in the cache

Hard Bonus challenge

The complete recipe for an optimistic update with a rollback – exactly as in the TanStack Query documentation.

🔒 Bonus challenge

Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.

How we handle your data

Summary

  • Server data is a copied price list: it isn't yours and it can go stale. It needs sharing, deduplication, refreshing and invalidation.
  • useQuery is the noticeboard keeper. queryKey is the label and contains everything the query depends on; staleTime says how long the copy stays fresh.
  • useMutation + invalidateQueries: after a change on the server, cross out the old copies.
  • Suspense + use()/useSuspenseQuery: waiting and errors described one level up, in one place.
  • Order everything at once and as early as possible (loaders, prefetch) – avoid waterfalls.
🧩 Where do the things not defined here come from (QueryClient, QueryClientProvider, useQuery, fetchProducts)
QueryClient, QueryClientProvider, useQuery – from the library @tanstack/react-query: the data fetching and caching library (lesson 14).
fetchProducts – from the file src/course/fakeApi.en.ts. The course fake API: data and functions pretending to be a server (with a delay, sometimes even with an error). In a real app you would call fetch() here.
export interface Product {
id: number
name: string
category: 'fruit' | 'vegetables' | 'bakery' | 'dairy'
price: number
inStock: boolean
}
export async function fetchProducts(query = '', signal?: AbortSignal): Promise<Product[]> {
await sleep(randomDelay())
signal?.throwIfAborted()
const q = query.trim().toLowerCase()
return products.filter((p) => p.name.toLowerCase().includes(q))
}
CSS classes like row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).