Difficulty: Advanced
Beyond basic queries, React Query provides powerful tools for mutations (write operations), optimistic updates (instant UI feedback), and infinite queries (pagination/infinite scroll). These patterns handle the complex server-state scenarios that would require hundreds of lines of manual code.
useMutation is the write counterpart to useQuery. It handles POST, PUT, DELETE operations and provides onMutate, onSuccess, onError, and onSettled callbacks. The key pattern is to invalidate related queries after a successful mutation so the UI shows fresh data. For example, after creating a new todo, you invalidate the ['todos'] query to trigger a refetch.
Optimistic updates take this further. Instead of waiting for the server to confirm a mutation, you immediately update the UI to reflect the expected result. In onMutate, you cancel outgoing queries, snapshot the current data, and manually set the cache to the optimistic value. If the server returns an error, onError rolls back to the snapshot. This gives the user instant feedback, making the app feel faster.
Infinite queries with useInfiniteQuery handle paginated or cursor-based data. You provide a getNextPageParam function that extracts the next cursor/page from the last response. The hook returns pages (an array of page results), fetchNextPage, hasNextPage, and isFetchingNextPage. This maps directly to infinite scroll UIs or 'Load More' buttons.
Prefetching is another advanced technique. queryClient.prefetchQuery lets you start fetching data before the user navigates to a page (e.g., on hover over a link). When they actually navigate, the data is already in cache and the page renders instantly. Combined with staleTime, this creates a near-instant navigation experience.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
onSuccess: () => {
// Refetch the todos list after adding
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<button
onClick={() => mutation.mutate({ title: 'New Todo' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
);
}
invalidateQueries marks the ['todos'] cache as stale and triggers a refetch. The list updates with the new todo from the server.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update cache
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === updatedTodo.id ? { ...t, ...updatedTodo } : t)
);
return { previousTodos }; // context for rollback
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Always refetch to ensure server truth
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
onMutate saves a snapshot and updates the cache optimistically. If the server rejects the mutation, onError rolls back. onSettled always refetches to sync with server truth.
import { useInfiniteQuery } from '@tanstack/react-query';
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/posts?page=${pageParam}`).then(r => r.json()),
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 1,
});
return (
<div>
{data?.pages.map(page =>
page.items.map(post => <p key={post.id}>{post.title}</p>)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No More Posts'}
</button>
</div>
);
}
getNextPageParam extracts the next page number from the server response. data.pages is an array of all fetched pages. hasNextPage is true as long as getNextPageParam returns a value.
useMutation, optimistic updates, infinite queries, query invalidation, prefetching