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
- If
personchanges quickly, old requests may overwrite newer ones. - If an instance of this component is rendered multiple times, the response may come out of order, and the latest data will not be shown. One solution is to add a cleanup function with an ignore flag.
2. No loading state
- Users see a blank screen until data arrives.
- Ideally, the UI should show a loading indicator or stale data to the user for better UX.
3. No error handling
- Failures crash silently.
- Any fetch request can fail, sometimes for known reasons. These errors should be caught, and the UI should respond.
4. Fetch request is never aborted
- Even if this component is unmounted, the fetch request will go through and use resources.
- This causes wasted network and memory when unmounted.
5. No caching
Every re-render triggers another request. Responses to a data fetch request should be cached. Caching provides the following improvement opportunities:
- Showing cached data immediately displays relevant information. The stale data can be updated later. This is better than showing a loading UI.
- Fewer requests to the server. If responses to fetch requests are keyed, there will be only one request for all components fetching the same data.
6. Network waterfall
- Multiple components may re-fetch the same data unnecessarily.
- If the pattern shown in the example is followed in other components in an app tree, this may produce a waterfall effect while rendering.
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
isLoading: trueonly on the initial fetch when there is no cached data.isFetching: truewhenever any request is in-flight for that query, including 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.
- Stale-While-Revalidate: When a component mounts,
useQuerychecks the cache. If cached (stale) data for thatqueryKeyexists, it is shown immediately. Simultaneously, a background fetch is triggered to get fresh data. When the new data arrives, the UI updates seamlessly. This avoids loading spinners on subsequent views. - Request Deduplication: If multiple components on the page use the same
queryKey(e.g., [‘user‘, ‘current‘]), TanStack Query will only make one network request. All components will be subscribed to the result of that single request.
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
- Don’t fetch with bare
useEffectit leads to bugs and inefficiency. - Use TanStack Query for caching, deduplication, retries, and abort signals.
- Provide clear loading and error states for a better user experience.
- Let queries manage stale data and background updates instead of reinventing patterns.
- Optimize network usage by deduplicating requests across components.







