Data Fetching Without the useEffect Dance

The useState + useEffect pattern for fetching data is 15 lines of incidental complexity around 1 line of actual work. TanStack Query replaces all of it with a single hook call and adds caching, retries, and deduplication for free.

June 7, 20265 min read1 / 2

There is one line that actually does the work:

TypeScript
const flights = await searchFlights(params);

Everything else in the typical fetch-with-effect pattern is noise: loading state, error state, cleanup to prevent stale responses, an abort controller, and an empty dependency array that you hope is correct.

The work is one line. The infrastructure is fifteen.

The Pattern Everyone Has Written

TypeScript
function FlightResults({ params }: { params: FlightSearch }) { const [flights, setFlights] = useState<Flight[]>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); useEffect(() => { let cancelled = false; setIsLoading(true); setError(null); const controller = new AbortController(); searchFlights(params, { signal: controller.signal }) .then(data => { if (!cancelled) setFlights(data); }) .catch(err => { if (!cancelled && err.name !== 'AbortError') setError(err.message); }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; controller.abort(); }; }, [params.destination, params.startDate, params.endDate]); }

This has nothing to do with flights. It is the same boilerplate for every async value in the application -- textbook incidental complexity. And it does not even cover caching -- search the same route twice and both requests go to the network.

TanStack Query Is a Cache, Not a Fetch Library

The mental model that helps: TanStack Query does not fetch data. It manages a local cache that happens to work very well with async data. This is a different concern from avoiding cascading effects -- that post fixes how you react to data changes; this one eliminates the boilerplate of getting data in the first place.

useQuery says: "Give me whatever is in the cache for this key. If there is nothing cached, or if the cache is stale, run this function to populate it."

TypeScript
import { useQuery } from '@tanstack/react-query'; function FlightResults({ params }: { params: FlightSearch }) { const { data: flights, isLoading, error } = useQuery({ queryKey: ['flights', params], queryFn: () => searchFlights(params), }); }

Three lines replace eighteen. Loading state, error state, and cleanup come from the hook. AbortController handling is built in.

What You Get for Free

Caching. Search Tokyo → Paris. Go back and search Tokyo → Paris again. The second search returns instantly from cache.

Deduplication. Two components that call useQuery with the same key at the same time share one network request, not two.

Background refetching. When the user switches tabs and returns, TanStack Query silently refetches to check for fresh data.

Automatic retries. Failed requests retry three times by default before surfacing the error.

These are features most teams eventually want to build. TanStack Query has them already.

useEffect pattern: 15 lines of loading/error/cleanup boilerplate wrapping 1 line of fetch; useQuery: 3 lines, same 1 line of fetch, caching and retries automatic ExpanduseEffect pattern: 15 lines of loading/error/cleanup boilerplate wrapping 1 line of fetch; useQuery: 3 lines, same 1 line of fetch, caching and retries automatic

The Query Key

The query key is how TanStack Query identifies a cached result. Two queries with the same key share the same cache entry.

TypeScript
// Different keys → different cache entries useQuery({ queryKey: ['flights', { destination: 'Tokyo', date: '2026-07' }], queryFn: ... }) useQuery({ queryKey: ['flights', { destination: 'Osaka', date: '2026-07' }], queryFn: ... }) // Same key → same cache entry, no duplicate request useQuery({ queryKey: ['flights', { destination: 'Tokyo', date: '2026-07' }], queryFn: ... }) useQuery({ queryKey: ['flights', { destination: 'Tokyo', date: '2026-07' }], queryFn: ... })

Include every parameter that changes the response in the key. TanStack Query uses deep equality, so passing the full params object is fine.

Status vs Booleans

TanStack Query exposes both:

TypeScript
const { status, isLoading, isPending, isFetching, error, data } = useQuery({ ... }); // status is a discriminated union: 'pending' | 'error' | 'success' if (status === 'pending') return <Spinner />; if (status === 'error') return <ErrorMessage message={error.message} />; return <FlightList flights={data} />;

isPending is true on the very first fetch when there is no data yet. isFetching is true any time a request is in flight, including background refetches. isLoading (deprecated name in v5, now isPending) -- use isPending in new code.

When Not to Use TanStack Query

Server components. When you can await a data call directly inside a server component, do that. The data never touches the client bundle, there is no loading state to manage, and Suspense handles the fallback:

TypeScript
// Server component -- no TanStack Query needed async function ItineraryPage({ id }: { id: string }) { const itinerary = await getItinerary(id); // direct await return <ItineraryView data={itinerary} />; }

Use TanStack Query for client components that need to fetch data interactively -- search-as-you-type, lazy-loaded panels, mutations triggered by user action.

The Essentials

  1. The useState + useEffect pattern for fetching is 15 lines of incidental complexity. TanStack Query reduces it to 3 lines and handles cancellation, deduplication, retries, and caching automatically.
  2. The query key is the cache key. Include every parameter that affects the response. Same key = same cache entry = shared request.
  3. TanStack Query is a cache library, not a fetch library. It does not care how data is fetched -- it cares about storing, invalidating, and re-validating the result. This is what makes features like instant second searches possible.

Further Reading and Watching