Optimistic UI is a pattern where the client interface updates immediately as if a server request has already succeeded. We "lie" to the user to provide a zero-latency experience, then reconcile that lie once the server responds.
Instead of showing a loading spinner while waiting for a POST or PATCH request, we manually update the TanStack Query cache.
onMutate LifecycleTo implement this in TanStack Query, we use the useMutation hook. The key is the onMutate function, which runs before the actual mutation function starts.
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
// 1. When mutate is called:
onMutate: async (newTodo) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ["todos"] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(["todos"]);
// Optimistically update to the new value
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
// Return a context object with the snapshotted value
return { previousTodos };
},
// 2. If the mutation fails:
onError: (err, newTodo, context) => {
queryClient.setQueryData(["todos"], context.previousTodos);
},
// 3. Always refetch after error or success to sync with server:
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});The Rollback is the most critical part of a Senior-level UX. If we don't handle failures, the UI will show data that doesn't exist on the server, leading to a ghost state where the user thinks a task is completed when it isn't.
Note: Always use
queryClient.cancelQueriesinonMutate. If we don't, a background fetch might finish while our mutation is in flight, overwriting our optimistic UI with old data from the server.
| Use Case | Recommended? | Why? |
|---|---|---|
| Like/Heart Buttons | ✅ Yes | Low risk, high frequency. |
| Form Submissions | ❌ No | Better to show a loading state as users expect validation. |
| Deleting Items | ✅ Yes | Makes the app feel snappy and lightweight. |
| Financial Transactions | ❌ No | Accuracy is more important than perceived speed. |
Fetching data in chunks is essential for performance when dealing with large datasets (like social media feeds or product lists). TanStack Query provides the useInfiniteQuery hook specifically to handle Load More patterns and infinite scrolling.
Unlike a standard useQuery, useInfiniteQuery tracks multiple pages of data within a single cache entry. It uses a pageParam to keep track of where the next request should start (e.g., an offset or a cursor).
useInfiniteQueryTo implement an infinite list, we need to define how to fetch the next page and how to determine the next parameter.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =
useInfiniteQuery({
queryKey: ["projects"],
queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// If the last page has data, return the next cursor
// Return 'undefined' if there are no more pages
return lastPage.nextCursor ?? undefined;
},
});The data object returned by useInfiniteQuery is structured differently than a standard query:
data.pages: An array containing all the fetched pages [ { data, nextCursor }, { data, nextCursor } ].data.pageParams: An array of the parameters used to fetch each page.To render this, we typically flat map the pages:
{
data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.projects.map((project) => (
<p key={project.id}>{project.name}</p>
))}
</React.Fragment>
));
}There are two common ways to trigger the next fetch:
fetchNextPage().react-intersection-observer to detect when a sentinel element at the bottom of the list enters the viewport.Pro Tip: Always check
hasNextPageandisFetchingNextPagebefore callingfetchNextPageto prevent duplicate network requests.
| Feature | Infinite Query | Standard Pagination |
|---|---|---|
| UX Pattern | Infinite Scroll / Load More | Page Numbers (1, 2, 3...) |
| State | Appends new data to old data | Replaces old data with new data |
| Use Case | Feeds, Comments, Search results | Dashboards, Data tables, Admin panels |
Even with efficient caching, a Senior UX requires making the app feel like it is running locally. This section covers how to eliminate loading states entirely and how to use the official tools to debug our cache.
Prefetching is the act of loading data into the cache before the user actually navigates to a component. The most common trigger is hovering over a link or a list item.
const queryClient = useQueryClient();
const prefetchTodo = async (id) => {
// This will populate the cache for the todo detail page
await queryClient.prefetchQuery({
queryKey: ["todo", id],
queryFn: () => fetchTodoById(id),
staleTime: 1000 * 60, // Ensure it's considered fresh for at least 1 minute
});
};
// In our component:
<button onMouseEnter={() => prefetchTodo(todo.id)}>View Details</button>;The TanStack Query DevTools are non negotiable for performance tuning. They allow us to see the exact state of every query in our application in real-time.
gcTime (Garbage Collection) to be deleted.Performance Tip: If there are too many queries in the Fetching state simultaneously, consider increasing our
staleTimeor checking for unnecessary component remounts.
gcTime)Formerly known as cacheTime, gcTime determines how long inactive data stays in the cache before it is wiped to free up memory.