Skip to content

Frontend

TanStack Query (React Query) — Why It Replaced 90% of useEffect Data Fetches

Every dashboard ships with the same broken pattern: useEffect + useState for data fetching. No caching. No retries. No background sync. You rewrite it every time. TanStack Query (formerly React Query) ships all of it out of the box. One hook—useQuery—handles fetches, caching, retries, stale-while-revalidate, and background refresh. Mutations handle writes with optimistic updates. Supabase realtime syncs live data. This is how production dashboards work in 2026.

🔄 📡

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.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.