The useEffect + fetch pattern is cargo-cult because it used to be necessary. In 2018, you had no choice — hooks were new, libraries were sparse, so you'd write useEffect(() => { fetch(...) }, [dep]) in every component. Now it's muscle memory, and it's broken. The pattern ignores caching (you fetch the same user a dozen times per app lifetime), ignores retries (network hiccup = white screen), ignores background sync (your data is stale for 30 seconds until you refocus the tab), and ignores optimistic updates (form submits feel sluggish because you wait for the server). TanStack Query solves every one of these problems with a single hook. Define your fetch function, call useQuery, and you get caching, retries, background refresh, and devtools. Mutations give you writes with automatic cache invalidation. For Rebuild Relief's location-sorting dashboard with hundreds of concurrent users, this isn't a luxury — it's foundational.
The Problem: Manual Data Fetching Breaks at Scale
Here's the useEffect pattern everyone writes:
// src/hooks/useRoutes.ts — broken
function useRoutes() {
const [routes, setRoutes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/routes')
.then(r => r.json())
.then(data => setRoutes(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, []);
return { routes, loading, error };
}
This hook ships with silent failures. Network timeout? The user sees a loading spinner forever. The user opens two tabs — both fetch independently, no deduplication. Switch to another tab and back — it fetches again because there's no cache. The data is stale for 30 seconds until they refresh. Add retry logic manually, and you've got try-catch + exponential backoff scattered across components. Every engineer writes it differently. By the time Rebuild Relief hired a fourth engineer, they'd invented four versions of this hook and started debating which one is "right." This is wasted energy.
TanStack Query exists because developers kept rewriting the same logic. Caching, retries, background sync, stale-while-revalidate, automatic refetch on window focus, request deduplication — these are solved problems. Why rewrite them?
The Pattern: useQuery in 3 Lines
Here's the same fetch with TanStack Query:
// src/hooks/useRoutes.ts — correct
import { useQuery } from '@tanstack/react-query';
export function useRoutes() {
return useQuery({
queryKey: ['routes'],
queryFn: async () => {
const res = await fetch('/api/routes');
if (!res.ok) throw new Error('Failed to fetch routes');
return res.json();
},
});
}
That's it. The useQuery hook automatically handles loading, error, and caching. Refetch on window focus? Built-in. Retry on failure? Built-in. Deduplicate parallel requests? Built-in. Use it in a component:
// src/components/RoutesList.tsx
import { useRoutes } from '@/hooks/useRoutes';
export function RoutesList() {
const { data: routes, isLoading, error } = useRoutes();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{routes?.map(route => (
<li key={route.id}>{route.name}</li>
))}
</ul>
);
}
Open two instances of this component. Both share the same cache — one network request, both render. Focus away and back — it refetches in the background without blocking the UI. The data stays fresh while you work. This is the baseline now.
Why This Stack Beats Manual Fetching
Automatic Caching: Multiple components need user data? Fetch once, cache automatically. TanStack Query deduplicates in-flight requests and shares the result. No network waste, no re-rendering race conditions.
Retries with Exponential Backoff: Network hiccup? TanStack Query retries 3 times by default with exponential backoff. Manual fetch? You get an error and show the user a blank screen. For a dashboard with flaky mobile networks, this is non-negotiable.
Background Sync: Refocus the window and TanStack Query refetches in the background, then updates the UI if data changed. Your users never see stale data. No manual "pull-to-refresh" patterns. Just works.
Stale-While-Revalidate: Serve cached data instantly, fetch fresh data in the background. Users see data immediately, no loading spinner, and the cache updates silently. This is the UX pattern every SaaS dashboard should ship.
Devtools: TanStack Query ships a devtools panel showing every query, its cache state, when it was last fetched, and what data it holds. Debugging is transparent. No more "why is this query running" — click the devtools and see exactly when and why.
Four Essential Patterns
Pattern 1: Paginated Data with Dependency Keys
Pagination is built-in via queryKey dependencies:
// src/hooks/useRoutes.ts
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
export function useRoutesPaginated() {
const [page, setPage] = useState(1);
const { data, isLoading, error } = useQuery({
queryKey: ['routes', page], // page included in key
queryFn: async () => {
const res = await fetch(`/api/routes?page=${page}`);
return res.json();
},
});
return { data, isLoading, error, page, setPage };
}
Change the page, and TanStack Query sees a new queryKey. It caches each page separately, so flipping between pages is instant (cached pages) or fetches only the new page. Users don't see spinners for pages they've already viewed.
Pattern 2: Infinite Queries for Scrolling
Infinite scroll without manual pagination logic:
// src/hooks/useRoutesInfinite.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export function useRoutesInfinite() {
return useInfiniteQuery({
queryKey: ['routes-infinite'],
queryFn: async ({ pageParam = 1 }) => {
const res = await fetch(`/api/routes?page=${pageParam}`);
return res.json();
},
getNextPageParam: (lastPage) => lastPage.nextPage,
});
}
In a component, useCallback + Intersection Observer and TanStack Query handles fetching the next batch. Your scroll list merges all pages automatically. Never manual array concatenation.
Pattern 3: Optimistic Updates with Mutations
Write with optimistic updates — show the change before the server confirms:
// src/hooks/useUpdateRoute.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useUpdateRoute() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (route) => {
const res = await fetch(`/api/routes/${route.id}`, {
method: 'PATCH',
body: JSON.stringify(route),
});
return res.json();
},
onMutate: async (newRoute) => {
// Optimistic update: show the change immediately
await queryClient.cancelQueries({ queryKey: ['routes'] });
const previous = queryClient.getQueryData(['routes']);
queryClient.setQueryData(['routes'], (old) =>
old?.map(r => r.id === newRoute.id ? newRoute : r)
);
return { previous };
},
onError: (err, newRoute, context) => {
// Revert if the mutation fails
queryClient.setQueryData(['routes'], context.previous);
},
onSuccess: () => {
// Refetch to sync with server
queryClient.invalidateQueries({ queryKey: ['routes'] });
},
});
}
User changes a route status. The UI updates instantly (optimistic). If the server rejects it, the UI reverts automatically. If it succeeds, the cache stays in sync. Forms feel snappy and reliable.
Pattern 4: Realtime Sync with Supabase
Combine TanStack Query with Supabase realtime for live data:
// src/hooks/useRoutesRealtime.ts
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { supabase } from '@/lib/supabase';
export function useRoutesRealtime() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['routes'],
queryFn: async () => {
const { data, error } = await supabase
.from('routes')
.select('*');
if (error) throw error;
return data;
},
});
useEffect(() => {
// Subscribe to realtime updates
const channel = supabase
.channel('routes-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'routes' },
() => {
// Invalidate cache when data changes on the server
queryClient.invalidateQueries({ queryKey: ['routes'] });
}
)
.subscribe();
return () => channel.unsubscribe();
}, [queryClient]);
return query;
}
When another user updates routes, Supabase broadcasts the change. Your query hook sees it and refetches. All connected dashboards sync automatically. No polling, no stale-data gaps, just live.
Six FAQs
How do I avoid overfetching after a mutation?
Use invalidateQueries to tell TanStack Query which caches to clear. After creating a route, call queryClient.invalidateQueries({ queryKey: ['routes'] }) and it refetches once. You can also use refetchType: 'all' to refetch all active queries or refetchType: 'active' for only those currently in use.
Can I prefetch data for faster navigation?
Yes. Call queryClient.prefetchQuery() in a router beforeLoad hook or on hover. The data fetches in the background, so when the user navigates, it's already cached. Feels instant.
How do I handle polling or periodic refetches?
Pass refetchInterval: 5000 (5 seconds) to refetch automatically. Or use refetchOnWindowFocus (enabled by default) to refetch when the user returns to the tab. Combine both for a responsive, fresh dashboard.
What if my API requires headers or auth tokens?
Use a queryClient with a defaultOptions interceptor or create a custom fetch wrapper. For Supabase, just use the Supabase client inside your queryFn — it handles auth automatically.
Can I cancel a query if the component unmounts?
TanStack Query cancels queries automatically on unmount. No hanging requests. If you need manual control, use abort on the queryFn and catch AbortError.
How do I test components with TanStack Query?
Wrap your test in a QueryClientProvider and mock the queryFn. TanStack Query's docs have a full testing guide. The library is test-friendly — mock the fetch, assert the UI, done.
The Bottom Line
Manual data fetching with useEffect is technical debt you write every day. TanStack Query eliminates it. Caching, retries, background sync, optimistic updates, devtools — shipped. Use useQuery for reads, useMutation for writes, and combine it with Zustand for client state. When you're ready to build dashboards that feel responsive and stay in sync, this is the stack that scales. For integration details and more patterns, see the Aidxn pricing page where we ship this stack in production.