React Query Advanced Patterns

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.

Code examples

useMutation with query invalidation

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.

Optimistic update pattern

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.

Infinite query for pagination

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.

Key points

Concepts covered

useMutation, optimistic updates, infinite queries, query invalidation, prefetching