Topics

On this page

Last updated on Dec 4, 2025

Data loading

Fetching data in React seems simple at first, but doing it right is crucial for performance, scalability, and user experience. A naive approach with useEffect often leads to issues like race conditions, missing loading/error states, unnecessary network requests, and poor caching

The problem with naive data fetching

If you want to fetch data from an useEffect manually, your code might look like this:

import { useState, useEffect } from 'react';  
import { fetchBio } from './api.js';  

export default function Page() {  

	const [person, setPerson] = useState('Alice');  
	const [bio, setBio] = useState(null);  

	useEffect(() => {
		setBio(null);  
		fetchBios(person).then(()=>{
			setBio(result); 
		});  
	}, [person]);
…

}

The example shown has the following problems:

1. Race condition 

2. No loading state 

3. No error handling

4. Fetch request is never aborted

5. No caching 

Every re-render triggers another request. Responses to a data fetch request should be cached. Caching provides the following improvement opportunities:

6. Network waterfall 

This can be addressed by creating a custom hook around fetchBios; however, this is a well-explored problem, and multiple libraries have attempted to solve it.

The TanStack Query solution

We recommend using @tanstack/query. TanStack Query provides out-of-the-box solutions to all the above problems. It manages caching, deduplication, retries, error states, and request cancellation automatically.

1. Race condition 

TanStack Query is aware of the query’s state. When you trigger a new fetch for a given queryKey, it knows that any in-flight requests for that same key are now outdated. It will simply ignore the results from all except the most recently initiated request. You don’t have to write any extra code.

2. Loading state 

useQuery provides flags like isLoading and isFetching to distinguish between initial load and background refetches

This enables both full-page loaders and subtle refresh indicators.

function UserProfile() {
  const { isLoading, isFetching, data } = useQuery({
    queryKey: ['userProfile'],
    queryFn: () => fetch('/api/user').then(res => res.json()),
  });

  // Shows a full-page loader on the first load
  if (isLoading) {
    return <div>Loading your profile...</div>;
  }

  return (
    <div>
      <h1>{data.name}</h1>
      {/* Shows a subtle indicator during background refetches */}
      {isFetching && <span>(Updating...)</span>}
      <p>{data.bio}</p>
    </div>
  );
}

3. Error management

The useQuery hook automatically catches any errors thrown by your queryFn. It provides an isError boolean flag and an error object containing the error details. It also has a built-in, configurable retry mechanism.

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

function Post({ postId }) {
  const { data, isError, error, isLoading } = useQuery({
    queryKey: ['post', postId],
    queryFn: async () => {
      const response = await fetch(`/api/posts/${postId}`);
      if (!response.ok) {
        // This thrown error will be caught by TanStack Query
        throw new Error('Post not found!');
      }
      return response.json();
    },
    retry: 1, // Will retry a failed request 1 time
  });

  if (isLoading) {
    return 'Loading post...';
  }

  // If the fetch fails, isError becomes true and `error` is populated
  if (isError) {
    return <div>Error: {error.message}</div>;
  }

  return <div>{data.content}</div>;
}

4. Request abortion

TanStack Query passes an AbortSignal to the query function. This cancels in-flight requests automatically when the component unmounts or a query becomes outdated.

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

// Your fetch function should accept the signal
const fetchProjects = async ({ signal }) => {
  const res = await fetch('/api/projects', { signal }); // Pass the signal here
  return res.json();
};

function ProjectsList() {
  // TanStack Query automatically provides the signal to the queryFn
  const { data } = useQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
  });

  /* If this component unmounts while the fetch is in progress,
  * the `AbortSignal` will be triggered, and the browser will
  * cancel the network request. No manual AbortController needed.
  **/

  return (/* ... render projects ... */);
}

5. Caching and deduplication

Caching is a core feature. It’s on by default.

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

const fetchUser = async (id) => fetch(`/api/users/${id}`).then(res => res.json());

// Component 1: User's avatar in the navbar
function UserAvatar({ userId }) {
  const { data } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
  return <img src={data?.avatarUrl} alt={data?.name} />;
}

// Component 2: User's profile on the main page
function UserProfile({ userId }) {
  const { data } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
  return <h1>Welcome, {data?.name}</h1>;
}

// In your App:
function App() {
  const userId = '123';
  return (
    
      <header><UserAvatar userId={userId} /></header>
      <main><UserProfile userId={userId} /></main>
    </>
  );
}
// Even though `useQuery` is called twice with the same key `['user', '123']`,
// only ONE network request to `/api/users/123` will be made.

Key takeaways


Credits

Authored by Sayed Sayed Sayed Taqui Director of Engineering – React , Imran Imran Imran Sayed Senior WordPress Engineer , Ayush Ayush Ayush Nirwal Senior Software Engineer , Amoghavarsha Amoghavarsha Amoghavarsha Kudaligi Senior Software Engineer , Mayank Mayank Mayank Rana Senior Software Engineer