Updated June 2026. Tested on React 19 and TanStack Query 5. Part of the Techalyst React series.

Fetching data with useEffect works, but you end up writing the same things over and over: a loading flag, an error flag, a cleanup to ignore stale responses, and nothing for caching or refetching. TanStack Query, the library formerly called React Query, does all of that for you. It treats data from your server as its own kind of state and manages it properly, which removes a surprising amount of code.

Fetching with useQuery

You give useQuery a key and a function that returns a promise, and it hands back the data along with the loading and error states already tracked:

import { useQuery } from '@tanstack/react-query';

function Products() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(r => r.json()),
  });

  if (isLoading) return <Spinner />;
  if (error) return <p>Failed to load.</p>;
  return <ProductList items={data} />;
}

That is the whole loading, error, and data dance, handled. No useState, no useEffect, no stale-response guard.

The query key is the point

The queryKey is how Query identifies and caches a request. Two components asking for ['products'] share one cached result and one network call, not two. When the data depends on a parameter, you put it in the key, and changing it fetches and caches separately:

useQuery({
  queryKey: ['product', id],   // cached per id
  queryFn: () => fetchProduct(id),
});

What you get for free

This is why people switch. Out of the box, Query caches results so revisiting a screen is instant, refetches in the background to keep data fresh while showing the cached version immediately, deduplicates identical requests, and retries failed ones. All the things you would otherwise build by hand, and get subtly wrong, come included. It treats server data as cached state that can go stale, which is a much better model than pretending it is local state you fetched once.

Writing data with useMutation

Reads use useQuery, writes use useMutation. After a successful write, you invalidate the affected queries so they refetch and the UI reflects the change:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddProduct() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (product) =>
      fetch('/api/products', { method: 'POST', body: JSON.stringify(product) }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] }); // refetch the list
    },
  });

  return <button onClick={() => mutation.mutate(newProduct)}>Add</button>;
}

Invalidating the ['products'] key tells Query that the cached list is out of date, so it refetches and the new item appears without you manually updating any state.

Setup

Wrap your app once in a provider with a client:

const queryClient = new QueryClient();

<QueryClientProvider client={queryClient}>
  <App />
</QueryClientProvider>

Wrapping up

TanStack Query exists because server data is not the same as UI state, it lives elsewhere, it goes stale, and it benefits from caching. useQuery fetches and tracks loading and error states from a key and a function, sharing and caching by that key, while useMutation handles writes and invalidating keys refreshes the affected data. It replaces the repetitive useEffect fetching pattern with something that caches, refetches, and dedupes for you. For any app that talks to an API more than trivially, it is the tool worth adopting.