Featured resource
2025 Tech Upskilling Playbook
Tech Upskilling Playbook

Build future-ready tech teams and hit key business milestones with seven proven plays from industry leaders.

Check it out
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Beginner
Last updated
Oct 21, 2025
Duration
45m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use.
Table of Contents
  1. 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 the json-server in one Terminal. After it's running, use npm 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 of factory.json into db.json.

  2. 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 your QueryClient and wrap your app in a QueryClientProvider. This will allow all components in your app to access the query client and use hooks like useQuery and useMutation.

    Instructions
    1. Wire the QueryClient in src/main.jsx
      • Create a QueryClient instance before the return statement and wrap <App /> with <QueryClientProvider client={queryClient}>.
      • This is necessary so all useQuery/useMutation hooks can access the shared cache.
      • Both QueryClient and QueryClientProvider have already been imported from @tanstack/react-query.

    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 in axios.js, which sets up an Axios instance for making HTTP requests to the json-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 implement fetchStocksPage in a later step. All of these helper functions are meant to be called by your queries and mutations within the useStock 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 use useQuery for fetching and useMutation 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
    1. Implement useStocks in src/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.
    2. 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 contains data, isLoading, isError, isFetching, etc. UI reads these to show loading/error/refresh states.
      • Create add and remove mutation variables:
        useMutation({ 
            mutationFn: createStock
        })
        useMutation({ 
            mutationFn: deleteStock
        })
        
      • Return { query, add, remove } so the UI can consume them.

    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
    1. 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.
    2. 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.

    What's next

    After this working baseline, move on to optimistic updates and query invalidation in the next step.

  3. 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 optimistic onMutate/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.
    • onError: run if the mutation failed. Use this to restore the snapshot context.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 and remove mutation handlers in src/hooks/useStocks.js.

    Instructions
    1. Open src/hooks/useStocks.js

    2. For the add mutation:

      • Add an onMutate arrow function async (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 list
          const optimistic = { id: String(max + 1), ...newStock }
          
          qc.setQueryData(['stocks'], (old = []) => {
              return [optimistic, ...old]
          })
          
        • returns { previous } as the context
      • 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 call qc.invalidateQueries({ ['stocks'] }).
    3. 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.

    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.

  4. 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 (implement fetchStocksPage)
    • src/hooks/useStocks.js (switch to useInfiniteQuery 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 like fetchNextPage, hasNextPage, and isFetchingNextPage.

    To begin, you will need to implement a new API helper function that fetches data in pages from the server.

    Instructions
    1. Implement fetchStocksPage in src/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) and limit (page size) matching TanStack Query's useInfiniteQuery signature.
    2. 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 with limit items.
    3. Determine if there are more pages
      • Attempt to read x-total-count from res.headers to compute a hasMore 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, infer hasMore when the returned page length equals limit.
    4. Return the paged data
      • Return { stocks: res.data, nextPage: hasMore ? pageParam + 1 : undefined }.

    Now that this helper function has been implemented, you will need to modify the useStocks hook to use useInfiniteQuery instead of useQuery along with this function.

    Instructions
    1. Switch useStocks to useInfiniteQuery in src/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] — includes limit to allow different page sizes to cache separately.
      • queryFn: ({ pageParam = 1 }) => fetchStocksPage({ pageParam, limit }) — calls the new paged fetcher with the current pageParam and limit.
      • getNextPageParam: (last) => last.nextPage — extracts the next page number from the last fetched page.
      • Keep your add/remove mutations.
    2. Update mutations to handle pagination
      • Within the onMutate function, modify the cancelQueries and getQueryData calls to include the limit 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 from flattened instead of previous.
      • 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's onMutate to filter out the deleted stock while preserving the pages shape.

    Lastly, you will need to update the StockList.jsx component to render the paged data and provide a way to load more pages.

    Instructions
    1. Update StockList.jsx to render pages
      • Modify useStocks call to pass a limit, such as 5.
      • Add the fetchNextPage and hasNextPage destructured from the query 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.

    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 the Load 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 the useEffect block and placing it between the useInfiniteQuery 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

George is a Pluralsight Author working on content for Hands-On Experiences. He is experienced in the Python, JavaScript, Java, and most recently Rust domains.

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.

Get started with Pluralsight