DEV Community

Cover image for Stop Using useEffect for Data Fetching: Understanding TanStack Query
victor mwangi
victor mwangi

Posted on

Stop Using useEffect for Data Fetching: Understanding TanStack Query

If you are still fetching data inside a useEffect hook, manually managing loading states, and storing server responses inside local component state, your frontend architecture is carrying unnecessary complexity.

Managing asynchronous API data — also called Server State — is one of the hardest problems in frontend development. Trying to handle it with traditional client-side state tools like Redux, Zustand, or plain React state often leads to repetitive, fragile, and difficult-to-maintain codebases.

That is where TanStack Query changes everything.

It is not just a data-fetching library. It acts as:

An intelligent cache
A server-state manager
A synchronization layer between your frontend and backend
A background updater for stale data

Let’s break down how it actually works.

Client State vs Server State

Before understanding TanStack Query, you need to separate two completely different categories of state.

1. Client State

This is synchronous state that exists entirely inside the browser.

Examples:

Modal visibility
Theme toggles
Sidebar state
Form inputs

This data is fully controlled by the frontend.

2. Server State

This is asynchronous state that lives remotely on a backend server or database.

Examples:

Product lists
User profiles
Notifications
Dashboard analytics
Orders from an API

Server state has unique challenges:

You do not own the data
Multiple users can modify it
It can become outdated instantly
It requires async network requests
It needs synchronization

Using useState + useEffect forces server state to behave like client state.

That creates problems like:

Duplicate requests
Boilerplate loading logic
Manual error handling
Race conditions
No caching
Difficult synchronization

TanStack Query solves these automatically.

Declarative Data Fetching with useQuery

TanStack Query replaces imperative fetching with a declarative approach.

The core building blocks are:

Query Key
Query Function

Example:

_**

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

function ProductList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('https://api.yourdomain.com/products');

      if (!res.ok) {
        throw new Error('Failed to fetch products');
      }

      return res.json();
    },
  });

  if (isLoading) {
    return <div>Loading products...</div>;
  }

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

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}_
Enter fullscreen mode Exit fullscreen mode

**
Why This Is Powerful

Instead of manually controlling the fetch lifecycle, you simply describe:

“This component depends on the products query.”

TanStack Query handles everything else automatically.

Network Request Deduplication

Imagine three different components all requesting the same product list:

Sidebar
Dashboard
Analytics widget

Without TanStack Query:

3 components
3 fetch calls
3 HTTP requests

With TanStack Query:

queryKey: ['products']

TanStack Query recognizes the identical query key and:

Sends only ONE network request
Shares the response across all components
Prevents network waterfalls

This significantly improves performance.

Understanding the Cache Architecture

TanStack Query uses a strategy called:

Stale-While-Revalidate

The workflow looks like this:

Serve cached data immediately
Fetch fresh data in the background
Update the UI automatically if data changed

This creates extremely fast-feeling applications because users instantly see cached content instead of loading spinners.

Client State vs Server State

Before understanding TanStack Query, you need to separate two completely different categories of state.

  1. Client State

This is synchronous state that exists entirely inside the browser.

Examples:

Modal visibility
Theme toggles
Sidebar state
Form inputs

This data is fully controlled by the frontend.

  1. Server State

This is asynchronous state that lives remotely on a backend server or database.

Examples:

Product lists
User profiles
Notifications
Dashboard analytics
Orders from an API

Server state has unique challenges:

You do not own the data
Multiple users can modify it
It can become outdated instantly
It requires async network requests
It needs synchronization

Using useState + useEffect forces server state to behave like client state.

That creates problems like:

Duplicate requests
Boilerplate loading logic
Manual error handling
Race conditions
No caching
Difficult synchronization

TanStack Query solves these automatically.

Declarative Data Fetching with useQuery

TanStack Query replaces imperative fetching with a declarative approach.

The core building blocks are:

Query Key
Query Function

Example:

_

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

function ProductList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('https://api.yourdomain.com/products');

      if (!res.ok) {
        throw new Error('Failed to fetch products');
      }

      return res.json();
    },
  });

  if (isLoading) {
    return <div>Loading products...</div>;
  }

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

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

_
Why This Is Powerful

Instead of manually controlling the fetch lifecycle, you simply describe:

“This component depends on the products query.”

TanStack Query handles everything else automatically.

Network Request Deduplication

Imagine three different components all requesting the same product list:

Sidebar
Dashboard
Analytics widget

Without TanStack Query:

3 components
3 fetch calls
3 HTTP requests

With TanStack Query:

queryKey: ['products']

TanStack Query recognizes the identical query key and:

Sends only ONE network request
Shares the response across all components
Prevents network waterfalls

This significantly improves performance.

Understanding the Cache Architecture

TanStack Query uses a strategy called:

Stale-While-Revalidate

The workflow looks like this:

Serve cached data immediately
Fetch fresh data in the background
Update the UI automatically if data changed

This creates extremely fast-feeling applications because users instantly see cached content instead of loading spinners.
Understanding staleTime

Default:

staleTime: 0

Meaning:

Data becomes stale immediately after fetching.

Example optimization:

staleTime: 1000 * 60 * 5

This means:

Data is considered fresh for 5 minutes
No refetch happens during that period
Components reuse cached memory

Best for:

Country lists
User settings
Static configurations
Understanding gcTime (Garbage Collection)

Previously called cacheTime.

Default:

gcTime: 1000 * 60 * 5

Meaning:

When no component uses a query anymore:

The query becomes inactive
A garbage collection timer starts
Cache is deleted after 5 minutes

This prevents memory leaks.

Automatic Background Refetching

TanStack Query automatically keeps your application synchronized.

It refetches data when:

1. Components Mount Again

When a component using the query reappears.

2. Window Focus Changes

If the user switches tabs and comes back, the data revalidates automatically.

3. Network Reconnects

If the internet disconnects and reconnects, TanStack Query immediately synchronizes stale data.

4. Polling Intervals

Example:

refetchInterval: 10000

This refetches data every 10 seconds.

Useful for:

Real-time dashboards
Notifications
Live analytics
Mutating Data with useMutation

Fetching data is only half the story.

What happens after:

POST requests
PUT updates
DELETE operations

The browser cache immediately becomes outdated.

Example:

_

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

const queryClient = useQueryClient();

const addProductMutation = useMutation({
  mutationFn: async (newProduct) => {
    const res = await fetch(
      'https://api.yourdomain.com/products',
      {
        method: 'POST',
        body: JSON.stringify(newProduct),
      }
    );

    return res.json();
  },

  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: ['products'],
    });
  },
});
Cache Invalidation

Instead of manually updating arrays in state:

setProducts(...)

You invalidate the query:

queryClient.invalidateQueries({
  queryKey: ['products'],
});

Enter fullscreen mode Exit fullscreen mode

TanStack Query then:

Marks the cache as stale
Automatically refetches fresh data
Synchronizes the UI with the database

This removes fragile state management logic entirely.

Using TanStack Query DevTools

Debugging async cache systems with console.log becomes painful quickly.

Install the DevTools:

npm install @tanstack/react-query-devtools

Setup:

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

import {
  ReactQueryDevtools,
} from '@tanstack/react-query-devtools';

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MainApplication />

      <ReactQueryDevtools
        initialIsOpen={false}
      />
    </QueryClientProvider>
  );
}_
Enter fullscreen mode Exit fullscreen mode

Query Lifecycle States

Inside the DevTools panel, queries appear in different lifecycle states:

State Meaning
Fresh Data is still trusted
Stale Data exists but needs revalidation
Fetching Active network request
Inactive No component currently uses the query

This gives you full visibility into your async cache architecture.

Top comments (0)