Skip to content

How to Handle Async Data Fetching in React Without useEffect

Problem

When I started building React apps, I used useEffect for everything related to data fetching. My components looked like this:

UserList.jsx
function UserList() {
const [users, setUsers] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false; // Required to prevent race conditions
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (!ignore) {
setUsers(data);
}
} catch (err) {
if (!ignore) {
setError(err.message);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
fetchUsers();
return () => {
ignore = true; // Cleanup required
};
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

This code works, but I kept running into issues:

  • Race conditions when props changed quickly
  • No caching - every component remount triggered a new fetch
  • Boilerplate code for loading/error states
  • Memory leaks when I forgot cleanup
  • No automatic refetching when user returned to the tab

I realized I was using the wrong tool for the job.

Environment

  • React 18.x
  • TanStack Query 5.x
  • Modern browser with ES6+ support

What happened?

The React team designed useEffect for synchronization with external systems, not for data fetching. I was forcing it to do something it wasn’t built for.

Here’s what I learned from the React docs:

useEffect is designed for “synchronization with external systems” - not data fetching

When I used useEffect for data fetching, I had to manually handle:

  • Loading states (useState)
  • Error handling (try/catch)
  • Cleanup (ignore variable pattern)
  • Race conditions (ignore checks everywhere)
  • No built-in caching or deduplication

The Reddit community confirmed this. One developer said:

“useEffect is almost never needed for syncing data. it’s used for managing side effects.”

Another comment:

“I consider useEffect to be a code smell at this point” when combined with TanStack Query

How to solve it?

I switched to TanStack Query (formerly React Query). Here’s the same component with TanStack Query:

UserList.jsx
import { useQuery } from '@tanstack/react-query';
function UserList() {
const {
data: users,
isPending,
isError,
error,
isFetching,
refetch,
} = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
},
staleTime: 1000 * 60, // Data fresh for 1 minute
gcTime: 1000 * 60 * 5, // Cache kept for 5 minutes
refetchOnWindowFocus: true,
});
if (isPending) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<div>
{isFetching && <span>Refreshing...</span>}
<button onClick={() => refetch()}>Refresh</button>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}

You can see that I reduced 35+ lines to about 20 lines. More importantly, I got these features for free:

  • Automatic caching - Data cached by queryKey
  • Deduplication - Identical requests merged automatically
  • Background refetching - Stale data refreshed automatically
  • Window focus refetch - Fresh data when user returns
  • Built-in states - isPending, isError, isFetching handled
  • No race conditions - TanStack Query handles cleanup

Setup: Wrap your app

First, I wrapped my app with QueryClientProvider:

App.jsx
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
);
}

Dynamic queries with parameters

For dynamic data like user profiles, I included the parameter in the queryKey:

UserProfile.jsx
function UserProfile({ userId }) {
const { data, isPending, isError } = useQuery({
queryKey: ['user', userId], // Key includes parameter
queryFn: () => fetchUser(userId),
enabled: !!userId, // Only fetch when userId exists
});
// TanStack Query automatically:
// - Deduplicates identical requests
// - Caches by query key
// - Refetches when userId changes
// - Handles cleanup on unmount
}

The reason

The key reason useEffect causes problems for data fetching is:

  1. Wrong abstraction - useEffect is for side effects, not data fetching
  2. Manual lifecycle management - You handle loading, error, cleanup yourself
  3. No caching - Every mount triggers a new fetch
  4. Race conditions - Requires manual ignore variable pattern
  5. No deduplication - Multiple components fetch same data separately

TanStack Query solves all these by providing a declarative API. I tell it what data I need, and it handles the how.

When useEffect IS still appropriate

useEffect remains the right choice for:

ThemeToggle.jsx
// CORRECT: useEffect for side effects/syncing
function ThemeToggle({ theme }) {
useEffect(() => {
localStorage.setItem('preferredTheme', theme);
document.body.className = theme;
}, [theme]);
return <div>Current theme: {theme}</div>;
}

Use useEffect for:

  • Syncing with external systems (localStorage, document.title, third-party widgets)
  • Subscriptions (WebSocket, event listeners)
  • Timers and intervals
  • Custom DOM manipulation

Comparison

AspectuseEffect (Manual)TanStack Query
Setup Code20-30 lines per fetch5-10 lines per query
Loading StatesManual useStateBuilt-in (isPending)
Error HandlingManual try/catchBuilt-in (isError, error)
CachingMust implement manuallyAutomatic (queryKey)
DeduplicationMust implement manuallyAutomatic
Race ConditionsManual cleanup requiredHandled automatically
Window Focus RefetchMust implement manuallyBuilt-in option
Background UpdatesMust implement manuallyBuilt-in (isFetching)
DevToolsNoneDedicated DevTools
Primary Use CaseSide effects/syncingServer state management

Summary

In this post, I showed why useEffect is the wrong tool for data fetching and how to use TanStack Query instead. The key point is: useEffect is for side effects and synchronization, not for fetching data.

TanStack Query gives me automatic caching, deduplication, loading/error states, and cleanup without the boilerplate. My code is cleaner, less buggy, and more maintainable.

If you’re still using useEffect for data fetching, give TanStack Query a try. It’s become the standard for React data fetching in 2026.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments