Advanced Data Fetching with TanStack Query - Optimistic Updates, Pagination, and WebSocket Integration
Ethan Miller
Product Engineer · Leapcell

Introduction
In the modern web development landscape, building performant, responsive, and data-rich user interfaces is paramount. Users expect seamless interactions and up-to-the-minute information. While traditional data fetching mechanisms often suffice for simple scenarios, the demands of complex applications necessitate more sophisticated solutions. This is where libraries like TanStack Query (formerly React Query) shine. It provides powerful primitives for managing server state, significantly simplifying data fetching, caching, synchronization, and error handling. Beyond its foundational capabilities, TanStack Query offers a suite of advanced features that can truly elevate the user experience and developer productivity. This article delves into three such pivotal features: optimistic updates for instant feedback, efficient pagination strategies for handling large datasets, and seamless integration with WebSockets for real-time data synchronization. Understanding and leveraging these advanced patterns can transform your application from merely functional to delightfully responsive and dynamic.
Core Concepts
Before diving into the advanced features, let's briefly define some core concepts within TanStack Query that will be referenced throughout this discussion.
- Query: Represents a request to fetch data from your backend. Queries are identified by a unique
queryKey
and automatically cached and re-fetched by TanStack Query. - Mutation: Represents an operation that modifies data on the backend (e.g., creating, updating, deleting). Mutations often have side effects and can trigger query invalidation.
- Query Client: The central instance that manages all your queries and mutations. It holds the cache and provides methods for interacting with it.
- Query Cache: Where TanStack Query stores the results of your queries, along with their metadata. This persistent cache allows for instant rendering of previously fetched data.
- Invalidation: The process of marking a cached query as "stale," prompting TanStack Query to re-fetch it in the background the next time it's accessed. This ensures data freshness.
These foundational concepts empower TanStack Query to intelligently manage your application's server state, providing a robust platform for implementing more advanced behaviors.
Optimistic Updates
Optimistic updates are a powerful technique to improve the perceived performance and responsiveness of your application. Instead of waiting for a server response before updating the UI, an optimistic update immediately applies the expected changes to the UI. If the server operation succeeds, the UI remains updated. If it fails, the UI is rolled back to its previous state. This provides instantaneous feedback to the user, making the application feel much faster.
How it Works
The core idea behind optimistic updates is to "assume success" on the client side. When a mutation is initiated, we:
- Cancel any outgoing queries that might interfere with the optimistic update.
- Snapshot the current query data that will be affected by the mutation. This allows for a graceful rollback if the mutation fails.
- Optimistically update the UI by directly modifying the cached query data to reflect the expected outcome of the mutation.
- Execute the actual mutation on the server.
- On success: Invalidate the affected queries to re-fetch the fresh data from the server, ensuring consistency.
- On error: Roll back the UI to the snapshot taken in step 2.
Implementation Example
Consider a scenario where a user toggles a "completed" status for a todo item.
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { updateTodoApi } from './api'; // Assume this is an API call function TodoItem({ todo }) { const queryClient = useQueryClient(); const { mutate } = useMutation({ mutationFn: updateTodoApi, onMutate: async (newTodo) => { // Step 1: Cancel any outgoing refetches for the todos query await queryClient.cancelQueries({ queryKey: ['todos'] }); // Step 2: Snapshot the previous value const previousTodos = queryClient.getQueryData(['todos']); // Step 3: Optimistically update the cache queryClient.setQueryData(['todos'], (old) => old ? old.map((t) => (t.id === newTodo.id ? newTodo : t)) : [] ); // Return a context object with the snapshot return { previousTodos }; }, onError: (err, newTodo, context) => { // Step 6: Rollback on error queryClient.setQueryData(['todos'], context.previousTodos); console.error('Failed to update todo:', err); }, onSettled: () => { // Step 5: Invalidate queries to ensure fresh data after the mutation queryClient.invalidateQueries({ queryKey: ['todos'] }); }, }); const toggleComplete = () => { mutate({ ...todo, completed: !todo.completed }); }; return ( <div> <input type="checkbox" checked={todo.completed} onChange={toggleComplete} /> <span>{todo.title}</span> </div> ); }
In this example, when a user clicks the checkbox, the UI immediately updates as if the request succeeded. If the updateTodoApi
call fails, the UI gracefully reverts to its previous state. This provides a significantly smoother user experience compared to waiting for a server round trip.
Application Scenarios
Optimistic updates are ideal for actions where immediate visual feedback is crucial and the likelihood of failure is relatively low. Common scenarios include:
- Toggling checkboxes (e.g., todo completion, marking as read).
- Liking/unliking posts.
- Adding/removing items from a cart (with graceful error handling for stock issues).
- Simple form submissions where immediate confirmation is helpful.
Pagination Queries
Handling large datasets effectively is a common challenge in web development. Displaying thousands of records at once is inefficient and detrimental to performance. Pagination is a widely adopted solution, allowing users to browse through data in manageable chunks. TanStack Query offers robust features for implementing various pagination strategies, ensuring efficient data fetching and a smooth user experience.
Types of Pagination
There are primarily two types of pagination:
- Offset-based pagination: This is the most common form, where you request data based on a
page
number andlimit
(orper_page
). The server returns a specific "offset" of entries from the total list. - Cursor-based pagination (Infinite Scrolling): This method requests data based on a "cursor" (usually an ID or timestamp) from the previous fetched set. It's often used for infinite scrolling experiences, where new data is appended as the user scrolls down.
Offset-Based Pagination with useQuery
For standard page-by-page navigation, useQuery
is perfectly suitable.
import { useQuery } from '@tanstack/react-query'; import { fetchPostsApi } from './api'; // Assumes API fetches posts by page function PostsList() { const [page, setPage] = useState(0); const { data, isPreviousData, isLoading, isError, error } = useQuery({ queryKey: ['posts', page], // queryKey changes with page number queryFn: () => fetchPostsApi(page), keepPreviousData: true, // Keep previously fetched data while new data is loading }); if (isLoading) return <div>Loading posts...</div>; if (isError) return <div>Error: {error.message}</div>; return ( <div> {data.posts.map((post) => ( <div key={post.id}>{post.title}</div> ))} <button onClick={() => setPage((old) => Math.max(old - 1, 0))} disabled={page === 0} > Previous </button> <button onClick={() => { if (!isPreviousData && data.hasMore) { // Ensure hasMore is returned by your API setPage((old) => old + 1); } }} disabled={isPreviousData || !data.hasMore} > Next </button> <span>Current Page: {page + 1}</span> </div> ); }
By adding page
to the queryKey
, TanStack Query will treat each page's data as a separate entry in the cache. The keepPreviousData: true
option is crucial here; it allows the previously fetched data to remain visible while the new page's data is loading, preventing a jarring blank state and improving user experience.
Cursor-Based Pagination with useInfiniteQuery
For infinite scrolling or "load more" patterns, useInfiniteQuery
is the go-to solution. It's specifically designed to fetch and manage lists that grow over time.
import { useInfiniteQuery } from '@tanstack/react-query'; import { fetchCommentsApi } from './api'; // Assumes API fetches comments with a 'cursor' function CommentsFeed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error, } = useInfiniteQuery({ queryKey: ['comments'], queryFn: ({ pageParam }) => fetchCommentsApi(pageParam), // pageParam is the cursor initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, // API should return a nextCursor }); if (isLoading) return <div>Loading comments...</div>; if (isError) return <div>Error: {error.message}</div>; return ( <div> {data.pages.map((page, i) => ( <React.Fragment key={i}> {page.comments.map((comment) => ( <div key={comment.id}>{comment.text}</div> ))} </React.Fragment> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'} </button> </div> ); }
useInfiniteQuery
stores the data in a flattened array, grouped by pages. getNextPageParam
is a critical function that tells TanStack Query how to get the pageParam
for the next fetch request, typically a cursor provided by the server response (lastPage.nextCursor
). This pattern is highly efficient as it only fetches new data as needed, reducing initial load times and server load.
Application Scenarios
- Offset-based pagination: Admin dashboards, search results, e-commerce product listings with fixed page numbers.
- Cursor-based pagination: Social media feeds, activity logs, chat histories, any "infinite scroll" experience.
WebSocket Integration
While TanStack Query excels at managing server state from RESTful APIs, modern applications often require real-time updates through WebSockets. Integrating WebSockets with TanStack Query allows you to push real-time changes directly into the Query Cache, ensuring your UI always reflects the latest state without constant polling or manual re-fetching.
The Challenge
Without proper integration, real-time updates from a WebSocket might not automatically reflect in your TanStack Query managed data. You would typically have to manually update component state or trigger re-fetches, leading to inconsistencies and boilerplate.
Solution with queryClient.setQueryData
and queryClient.invalidateQueries
The key to integrating WebSockets is to leverage queryClient.setQueryData
to directly update the cache and queryClient.invalidateQueries
to trigger re-fetches when appropriate.
import React, { useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { fetchStockPricesApi } from './api'; // API to fetch initial stock prices // Assume a WebSocket connection utility const connectWebSocket = (onMessage) => { const ws = new WebSocket('ws://localhost:8080/stock-prices'); ws.onmessage = (event) => onMessage(JSON.parse(event.data)); return ws; }; function StockPricesDisplay() { const queryClient = useQueryClient(); // Fetch initial stock prices const { data: stockPrices, isLoading, isError, error } = useQuery({ queryKey: ['stockPrices'], queryFn: fetchStockPricesApi, }); useEffect(() => { const ws = connectWebSocket((newPrice) => { // Update a specific stock price in the cache queryClient.setQueryData(['stockPrices'], (oldPrices) => { if (!oldPrices) return [newPrice]; // Handle initial state if cache is empty return oldPrices.map((price) => price.symbol === newPrice.symbol ? newPrice : price ); }); // Optionally, invalidate queries if a full re-fetch is desired for other data // For example, if a "portfolio total" depends on individual stock prices // queryClient.invalidateQueries({ queryKey: ['portfolioTotal'] }); }); return () => ws.close(); // Clean up WebSocket connection on unmount }, [queryClient]); if (isLoading) return <div>Loading stock prices...</div>; if (isError) return <div>Error: {error.message}</div>; return ( <div> <h2>Real-time Stock Prices</h2> {stockPrices.map((stock) => ( <div key={stock.symbol}> {stock.symbol}: ${stock.price.toFixed(2)} <span style={{ color: stock.change > 0 ? 'green' : 'red' }}> ({stock.change > 0 ? '+' : ''} {stock.change.toFixed(2)}%) </span> </div> ))} </div> ); }
In this example:
- We establish a WebSocket connection when the component mounts.
- When a new message (e.g., an updated stock price) is received from the WebSocket, we parse it.
- We then use
queryClient.setQueryData(['stockPrices'], ...)
to directly update thestockPrices
query in the cache. This modification immediately triggers a re-render of any components using this query, reflecting the real-time update. - Optionally, if an incoming WebSocket event affects derived data (e.g., a total calculated from a list of items), you can use
queryClient.invalidateQueries
to trigger a re-fetch of those dependent queries.
This approach provides a powerful way to marry the real-time capabilities of WebSockets with the robust state management of TanStack Query, offering a superior user experience with minimal manual state handling.
Application Scenarios
- Real-time dashboards: Stock tickers, cryptocurrency prices, live analytics.
- Chat applications: Instant message delivery.
- Notifications: Pushing real-time notifications to users.
- Collaborative editing: Synchronizing changes across multiple users.
Conclusion
TanStack Query extends far beyond basic data fetching, offering an ecosystem of powerful tools for managing complex server state. By mastering advanced features like optimistic updates, sophisticated pagination, and WebSocket integration, developers can craft applications that are not only efficient and robust but also exceptionally responsive and dynamic. These techniques contribute significantly to perceived performance and a delightful user experience, ensuring your applications stand out in today's demanding digital landscape. TanStack Query empowers you to build highly engaging, real-time user interfaces with confidence and ease.