- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech

Guided: State Management with TanStack Query
In this Code Lab, learners will work with a React app using TanStack Query for managing state. Over the course of the lab, you’ll participate in data fetching, mutations, query invalidation, optimistic updates, pagination, and scrolling queries against a mock API. By the end of this lab, you’ll gain hands-on experience with TanStack Query’s core features for state management in real-world applications.

Lab Info
Table of Contents
-
Challenge
Introduction
Overview
This Code Lab demonstrates modern client-side state management patterns using TanStack Query (v5) in a small React + Vite app. The project focuses on practical techniques you will use in real apps:
- Declarative data fetching with caching and background refetching (useQuery / useInfiniteQuery).
- Mutations for creating and deleting data (useMutation).
- Query invalidation and refetching to keep UI and server state in sync.
- Optimistic UI updates with rollback for a snappy UX.
- Server-backed pagination and a minimal "Load more" paging UX.
You will implement the network layer, a composable data hook, and small UI components that exercise TanStack Query patterns end-to-end.
Scenario
The app is a small "PS-Stocks" dashboard that lists stock-like items, lets you add new items, and delete items. The data is served by a local json-server (see
src/db.json
). The goal is to show how to:- Fetch and cache lists with TanStack Query (
fetchStocks
). - Mutate data and keep the query cache consistent (
createStock
,deleteStock
). - Add optimistic updates with rollback in
useStocks
. - Implement pagination using server slices and
useInfiniteQuery
.
Additional info
info> See the
solutions/
directory for reference implementations. Keep in mind that these solutions are not definitive, so slightly different implementations are still perfectly acceptable so long as the desired behavior is achieved.To run the application, use
npm run api
to start thejson-server
in one Terminal. After it's running, usenpm run dev
to start the application in a separate Terminal. You can run these commands after each step to observe your implementations and changes. To view your app, go to your Web Browser tab and click the Refresh button, or alternatively, click the following link: {{localhost:5173}}If you ever want to "reset" your
db.json
file, simply copy and paste the contents offactory.json
intodb.json
. -
Challenge
Fetch and Mutate
Overview
This step will acquaint you with fetching, mutation, and invalidation behaviors using TanStack Query.
The main files you will work on in this step are:
src/main.jsx
src/hooks/useStocks.js
src/components/StockList.jsx
Some auxiliary files that will be referenced are:
src/api/axios.js
src/api/stocks.js
Queries
As defined by the official TanStack Query docs, a query is a "declarative dependency on an asynchronous source of data that is tied to a unique key". In simpler terms, queries are used to fetch and cache data from a server, allowing your application to stay in sync with backend data sources.
To get started on your queries, head to
main.jsx
to instantiate yourQueryClient
and wrap your app in aQueryClientProvider
. This will allow all components in your app to access the query client and use hooks likeuseQuery
anduseMutation
.Instructions
- Wire the
QueryClient
insrc/main.jsx
- Create a
QueryClient
instance before thereturn
statement and wrap<App />
with<QueryClientProvider client={queryClient}>
. - This is necessary so all
useQuery
/useMutation
hooks can access the shared cache. - Both
QueryClient
andQueryClientProvider
have already been imported from@tanstack/react-query
.
- Create a
Fetching and Mutating Data
While a query defines what data an application wants, the actual fetching of data is still performed by the built-in
fetch
API or libraries like Axios. TanStack Query simply manages the state, caching, and synchronization of that data within your React application. You can observe this inaxios.js
, which sets up an Axios instance for making HTTP requests to thejson-server
backend.In
stocks.js
, you will find multiple helper functions that interact with your Axios instance. All but one have already been implemented for you, but you will need to implementfetchStocksPage
in a later step. All of these helper functions are meant to be called by your queries and mutations within theuseStock
hook.You will need to implement the
useStocks
hook to fetch the list of stocks and provide mutation functions to add and remove stocks. You will useuseQuery
for fetching anduseMutation
for adding and removing stocks. Make sure to invalidate the relevant queries after mutations to keep the UI in sync with the server data.Instructions
- Implement
useStocks
insrc/hooks/useStocks.js
- Instantiate a query client variable with
useQueryClient()
at the start of the hook. - This gets the shared
QueryClient
instance to read and update cached queries and to trigger invalidations.
- Instantiate a query client variable with
- Implement the fetching and mutation logic:
- Create a query variable with:
useQuery({ queryKey: ['stocks'], queryFn: fetchStocks, staleTime: 30_000 })
queryKey: ['stocks']
— a stable key identifying this data set in the cache.queryFn: fetchStocks
— the function that loads data from the network.staleTime: 30000
— marks data fresh for 30 seconds to avoid unnecessary refetches during development.- The returned
query
object containsdata
,isLoading
,isError
,isFetching
, etc. UI reads these to show loading/error/refresh states.
- Create
add
andremove
mutation variables:useMutation({ mutationFn: createStock }) useMutation({ mutationFn: deleteStock })
- Return
{ query, add, remove }
so the UI can consume them.
- Create a query variable with:
Connecting to the UI
Now that you've setup your queries and mutations, it's time to connect them to the UI in
StockList.jsx
component.Instructions
- Wire the UI in
src/components/StockList.jsx
- At the start of the component, call
const { query, add, remove } = useStocks()
. - Destructure the query into data and state flags with
const { data, isLoading, isError, error, isFetching } = query
.
- At the start of the component, call
- Enable the component:
- Uncomment the early return states for loading and error along with the rendered JSX within the
return
statement. - After the early loading and error checks, normalize the data with
const items = data ?? []
for mapping.
- Uncomment the early return states for loading and error along with the rendered JSX within the
What's next
After this working baseline, move on to optimistic updates and query invalidation in the next step.
-
Challenge
Optimistic Updates and Invalidation
Overview
Now that you've setup data fetching and mutations, the next step is to implement optimistic updates with rollback and query invalidation using TanStack Query.
The main file you will work on in this step is:
src/hooks/useStocks.js
(you will edit the hook to add optimisticonMutate
/onError
/onSettled
handlers)
Optimistic Updates and Invalidation
Optimistic updates allow your UI to reflect the result of a mutation immediately, before the server confirms it. This creates a snappier user experience. The trade-off is you must implement a safe rollback mechanism for when the server responds with an error.
Invalidation ensures that after a mutation, the relevant queries are marked stale and refetched from the server to synchronize the client cache with the authoritative server state.
The optimistic flow has three main parts:
-
onMutate
: run before the mutation request. Use this to:- cancel in-flight queries (
cancelQueries
on your query client(qc
) variable) to avoid races, - snapshot the previous cache state (
const previous = qc.getQueryData(...)
), - apply an optimistic update to the cache (
qc.setQueryData(...)
), - return a context object containing
previous
so you can rollback later.
- cancel in-flight queries (
-
onError
: run if the mutation failed. Use this to restore the snapshotcontext.previous
back into the cache. -
onSettled
: run after the mutation either succeeded or failed. Use this to invalidate queries (qc.invalidateQueries
) so the authoritative server state is fetched and reconciles with the optimistic updates.
Implementing optimistic add & delete
You will implement optimistic behavior for both
add
andremove
mutation handlers insrc/hooks/useStocks.js
.Instructions
-
Open
src/hooks/useStocks.js
-
For the
add
mutation:- Add an
onMutate
arrow functionasync (newStock) => {}
that:- Calls
await qc.cancelQueries({ ['stocks'] })
- Captures
const previous = qc.getQueryData(['stocks', limit]) || []
- Computes a deterministic optimistic id from the cached data
const max = previous.reduce((m, s) => { const n = Number(s.id) return Number.isFinite(n) ? Math.max(m, n) : m }, 0)
- Constructs an
optimistic
object and prepends it into the cached listconst optimistic = { id: String(max + 1), ...newStock } qc.setQueryData(['stocks'], (old = []) => { return [optimistic, ...old] })
- returns
{ previous }
as the context
- Calls
- Add
onError
with the signature(_, __, context) => {}
to restore the snapshot:if (context?.previous) qc.setQueryData(['stocks', limit], context.previous)
- Add
onSettled
with the signature() => {}
to callqc.invalidateQueries({ ['stocks'] })
.
- Add an
-
For the
remove
mutation:- Add an
onMutate
that:- Cancels queries
- Snapshots
previous
- Updates the cache to remove the item by id from the list
qc.setQueryData(['stocks'], (old = []) => { return old.filter((s) => String(s.id) !== String(id)) })
- Returns
{ previous }
.
- Add
onError
to restore snapshot. - Add
onSettled
to invalidate queries.
- Add an
What's next
After optimistic updates and invalidation are in place, move on to pagination and scrolling. Pagination requires adapting optimistic updates to the
pages
cache shape. -
Challenge
Pagination and Scrolling
Overview
The last feature to familiarize yourself with is server-backed pagination using TanStack Query. This feature allows you to load data in chunks (pages) rather than fetching the entire dataset at once
The main files you will work on in this step are:
src/api/stocks.js
(implementfetchStocksPage
)src/hooks/useStocks.js
(switch touseInfiniteQuery
and expose fetchNextPage)src/components/StockList.jsx
(render pages and add a Load more button)
Pagination
Pagination refers to the process of dividing a large dataset into smaller, manageable chunks (pages) that can be loaded and displayed incrementally. This approach improves performance and user experience by reducing initial load times and memory usage as trying to load an entire dataset at once can be inefficient and overwhelming for both the client and server.
A stable slicing strategy (e.g., _start/_limit) ensures distinct pages and avoids overlaps. For TanStack Query the recommended primitive is
useInfiniteQuery
, which coordinates multiple page fetches and exposes helpers likefetchNextPage
,hasNextPage
, andisFetchingNextPage
.To begin, you will need to implement a new API helper function that fetches data in pages from the server.
Instructions
- Implement
fetchStocksPage
insrc/api/stocks.js
- Edit the method signature to
async ({ pageParam = 1, limit = 5 })
- This allows the function to accept an object with
pageParam
(1-based page number) andlimit
(page size) matching TanStack Query'suseInfiniteQuery
signature.
- This allows the function to accept an object with
- Edit the method signature to
- Define the slice index and request a slice with axios
- Compute
const start = (pageParam - 1) * limit
and call:const res = await axiosInstance.get('/stocks', { params: { _start: start, _limit: limit }, })
- This requests a slice of stocks starting at
start
index withlimit
items.
- This requests a slice of stocks starting at
- Compute
- Determine if there are more pages
- Attempt to read
x-total-count
fromres.headers
to compute ahasMore
boolean with:const totalHeader = res.headers['x-total-count'] ?? res.headers['X-Total-Count'] ?? undefined const total = totalHeader ? Number(totalHeader) : undefined const hasMore = typeof total === 'number' ? pageParam * limit < total : res.data.length === limit
- If
x-total-count
is available, use it to determine if more pages exist; otherwise, inferhasMore
when the returned page length equalslimit
.
- If
- Attempt to read
- Return the paged data
- Return
{ stocks: res.data, nextPage: hasMore ? pageParam + 1 : undefined }
.
- Return
Now that this helper function has been implemented, you will need to modify the
useStocks
hook to useuseInfiniteQuery
instead ofuseQuery
along with this function.Instructions
- Switch
useStocks
touseInfiniteQuery
insrc/hooks/useStocks.js
- Replace
useQuery
with:useInfiniteQuery({ queryKey: ['stocks', limit], queryFn: ({ pageParam = 1 }) => fetchStocksPage({ pageParam, limit }), getNextPageParam: (last) => last.nextPage, staleTime: 1000 * 30, })
queryKey: ['stocks', limit]
— includeslimit
to allow different page sizes to cache separately.queryFn: ({ pageParam = 1 }) => fetchStocksPage({ pageParam, limit })
— calls the new paged fetcher with the currentpageParam
andlimit
.getNextPageParam: (last) => last.nextPage
— extracts the next page number from the last fetched page.- Keep your
add
/remove
mutations.
- Replace
- Update mutations to handle pagination
- Within the
onMutate
function, modify thecancelQueries
andgetQueryData
calls to include thelimit
in the query key - Compute a flattened variable with
const flattened = (previous?.pages ?? []).flatMap((p) => p.stocks ?? [])
to manipulate the full list of stocks across all pages. - Modify
max
to compute fromflattened
instead ofprevious
. - When updating the cache optimistically, preserve the
pages
shape:qc.setQueryData(['stocks', limit], (old) => { const oldPages = old?.pages ?? [] return { pages: [ { stocks: [optimistic, ...((oldPages[0]?.stocks) ?? [])], }, ...oldPages.slice(1), ], pageParams: old?.pageParams ?? [], } })
- Perform similar changes in the
remove
mutation'sonMutate
to filter out the deleted stock while preserving thepages
shape.
- Within the
Lastly, you will need to update the
StockList.jsx
component to render the paged data and provide a way to load more pages.Instructions
- Update
StockList.jsx
to render pages- Modify
useStocks
call to pass alimit
, such as 5. - Add the
fetchNextPage
andhasNextPage
destructured from thequery
object. - Flatten pages into a single list for rendering:
const items = data?.pages?.flatMap((p) => p.stocks) ?? []
- Add the
Load more
button by uncommenting it and placing it below the list rendering.
- Modify
After completing the instructitons above, you should have a working paginated stock list with optimistic add and delete functionality. You can now load more stocks in pages and see immediate UI updates when adding or removing stocks. Verify this by running the application and
json-server
backend, clicking theLoad more
button to fetch additional pages, and testing the add/remove stock features to ensure they work optimistically with pagination.OPTIONAL: You could also uncomment the functionality to fluctuate stock prices within
useStocks
by uncommenting theuseEffect
block and placing it between theuseInfiniteQuery
and mutation definitions. This will simulate real-time stock price changes every 5 seconds.
Finish
If you've reached this point, congratulations! You have successfully implemented state management features such as data fetching, mutations, optimistic updates, query invalidation, and pagination using TanStack Query in your React application. Feel free to explore further enhancements or optimizations as needed!
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.