From useEffect query to TanStack useQuery

If you’ve been fetching data in React with useEffect and manual state management, you’ve probably written code like this dozens of times:

const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  fetchData().then(setData).catch(setError).finally(() => setIsLoading(false));
}, []);

It works, but it’s verbose, error-prone, and lacks features modern apps need like caching, automatic refetching, and request deduplication.

The Old Way: useEffect + fetch

The traditional approach requires manually managing three pieces of state (data, loading, error), handling the entire request lifecycle yourself, and writing the same boilerplate repeatedly across components. Every time you need to fetch data, you’re reinventing the wheel.

Here’s a typical implementation:

import { useState, useEffect } from 'react';

interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
}

export default function ProductsList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchProducts = async () => {
      try {
        setIsLoading(true);
        const response = await fetch('/api/products');
        
        if (!response.ok) {
          throw new Error('Failed to fetch products');
        }
        
        const data = await response.json();
        setProducts(data);
      } catch (err) {
        setError(err instanceof Error ? err : new Error('Unknown error'));
      } finally {
        setIsLoading(false);
      }
    };

    fetchProducts();
  }, []);

  if (isLoading) return <div>Loading products...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <h3>{product.title}</h3>
            <p>${product.price}</p>
            <p>{product.description}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Problems with this approach:

  • Repetitive boilerplate – Every component needs the same state management
  • No caching – Navigate away and back? Fetch again
  • Race conditions – If the component unmounts during fetch, you’ll get state updates on unmounted components
  • No background updates – Data can become stale with no automatic refresh
  • Manual error handling – You’re responsible for every edge case
  • No request deduplication – Multiple components fetching the same data make multiple requests

The New Way: TanStack Query

TanStack Query (formerly React Query) eliminates all that boilerplate and provides a powerful, declarative API for async state management:

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

interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
}

const fetchProducts = async (): Promise<Product[]> => {
  const response = await fetch('/api/products');
  
  if (!response.ok) {
    throw new Error('Failed to fetch products');
  }
  
  return response.json();
};

export default function ProductsList() {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  if (isLoading) return <div>Loading products...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>Products</h1>
      <ul>
        {products?.map((product) => (
          <li key={product.id}>
            <h3>{product.title}</h3>
            <p>${product.price}</p>
            <p>{product.description}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

And your QueryClientProvider setup would go in your main app entry point (likely app.tsx or similar):

// resources/js/app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client';

const queryClient = new QueryClient();

createRoot(document.getElementById('app')!).render(
    <QueryClientProvider client={queryClient}>
    <App />
    </QueryClientProvider>
);

That’s it.

What Just Happened?

TanStack Query handles everything for you:

1. Automatic State Management No more useState for data, loading, and error states. The useQuery hook returns everything you need with proper TypeScript types inferred automatically.

2. Intelligent Caching The queryKey acts as a unique identifier. Navigate to another page and back? The cached data displays instantly while TanStack Query refetches in the background to ensure freshness.

3. Background Refetching When you focus the browser window or reconnect to the internet, TanStack Query automatically refetches stale data. Your users always see up-to-date information without manual intervention.

4. Request Deduplication If three components mount at the same time and all request ['products'], only one network request fires. All three components share the result.

5. Built-in Error Retry Failed requests automatically retry with exponential backoff. No more writing retry logic yourself.

6. Developer Experience Install the React Query DevTools and see every query, its status, cache time, and data in a beautiful debugging interface.

References

TanStack: https://tanstack.com/query/v5/docs/framework/react/reference/useQuery

Scroll to Top