- 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 apito start thejson-serverin one Terminal. After it's running, usenpm run devto 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.jsonfile, simply copy and paste the contents offactory.jsonintodb.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.jsxsrc/hooks/useStocks.jssrc/components/StockList.jsx
Some auxiliary files that will be referenced are:
src/api/axios.jssrc/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.jsxto instantiate yourQueryClientand wrap your app in aQueryClientProvider. This will allow all components in your app to access the query client and use hooks likeuseQueryanduseMutation.Instructions
- Wire the
QueryClientinsrc/main.jsx- Create a
QueryClientinstance before thereturnstatement and wrap<App />with<QueryClientProvider client={queryClient}>. - This is necessary so all
useQuery/useMutationhooks can access the shared cache. - Both
QueryClientandQueryClientProviderhave 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
fetchAPI 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-serverbackend.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 implementfetchStocksPagein a later step. All of these helper functions are meant to be called by your queries and mutations within theuseStockhook.You will need to implement the
useStockshook to fetch the list of stocks and provide mutation functions to add and remove stocks. You will useuseQueryfor fetching anduseMutationfor 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
useStocksinsrc/hooks/useStocks.js- Instantiate a query client variable with
useQueryClient()at the start of the hook. - This gets the shared
QueryClientinstance 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
queryobject containsdata,isLoading,isError,isFetching, etc. UI reads these to show loading/error/refresh states.
- Create
addandremovemutation 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.jsxcomponent.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
returnstatement. - 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/onSettledhandlers)
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 (
cancelQuerieson 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
previousso you can rollback later.
- cancel in-flight queries (
-
onError: run if the mutation failed. Use this to restore the snapshotcontext.previousback 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
addandremovemutation handlers insrc/hooks/useStocks.js.Instructions
-
Open
src/hooks/useStocks.js -
For the
addmutation:- Add an
onMutatearrow 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
optimisticobject 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
onErrorwith the signature(_, __, context) => {}to restore the snapshot:if (context?.previous) qc.setQueryData(['stocks', limit], context.previous) - Add
onSettledwith the signature() => {}to callqc.invalidateQueries({ ['stocks'] }).
- Add an
-
For the
removemutation:- Add an
onMutatethat:- 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
onErrorto restore snapshot. - Add
onSettledto 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
pagescache 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 touseInfiniteQueryand 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
fetchStocksPageinsrc/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'suseInfiniteQuerysignature.
- 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) * limitand call:const res = await axiosInstance.get('/stocks', { params: { _start: start, _limit: limit }, })- This requests a slice of stocks starting at
startindex withlimititems.
- This requests a slice of stocks starting at
- Compute
- Determine if there are more pages
- Attempt to read
x-total-countfromres.headersto compute ahasMoreboolean 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-countis available, use it to determine if more pages exist; otherwise, inferhasMorewhen 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
useStockshook to useuseInfiniteQueryinstead ofuseQueryalong with this function.Instructions
- Switch
useStockstouseInfiniteQueryinsrc/hooks/useStocks.js- Replace
useQuerywith:useInfiniteQuery({ queryKey: ['stocks', limit], queryFn: ({ pageParam = 1 }) => fetchStocksPage({ pageParam, limit }), getNextPageParam: (last) => last.nextPage, staleTime: 1000 * 30, }) queryKey: ['stocks', limit]— includeslimitto allow different page sizes to cache separately.queryFn: ({ pageParam = 1 }) => fetchStocksPage({ pageParam, limit })— calls the new paged fetcher with the currentpageParamandlimit.getNextPageParam: (last) => last.nextPage— extracts the next page number from the last fetched page.- Keep your
add/removemutations.
- Replace
- Update mutations to handle pagination
- Within the
onMutatefunction, modify thecancelQueriesandgetQueryDatacalls to include thelimitin 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
maxto compute fromflattenedinstead ofprevious. - When updating the cache optimistically, preserve the
pagesshape: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
removemutation'sonMutateto filter out the deleted stock while preserving thepagesshape.
- Within the
Lastly, you will need to update the
StockList.jsxcomponent to render the paged data and provide a way to load more pages.Instructions
- Update
StockList.jsxto render pages- Modify
useStockscall to pass alimit, such as 5. - Add the
fetchNextPageandhasNextPagedestructured from thequeryobject. - Flatten pages into a single list for rendering:
const items = data?.pages?.flatMap((p) => p.stocks) ?? [] - Add the
Load morebutton 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-serverbackend, clicking theLoad morebutton 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
useStocksby uncommenting theuseEffectblock and placing it between theuseInfiniteQueryand 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.