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:
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:
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:
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:
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:
- Wrong abstraction - useEffect is for side effects, not data fetching
- Manual lifecycle management - You handle loading, error, cleanup yourself
- No caching - Every mount triggers a new fetch
- Race conditions - Requires manual ignore variable pattern
- 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:
// CORRECT: useEffect for side effects/syncingfunction 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
| Aspect | useEffect (Manual) | TanStack Query |
|---|---|---|
| Setup Code | 20-30 lines per fetch | 5-10 lines per query |
| Loading States | Manual useState | Built-in (isPending) |
| Error Handling | Manual try/catch | Built-in (isError, error) |
| Caching | Must implement manually | Automatic (queryKey) |
| Deduplication | Must implement manually | Automatic |
| Race Conditions | Manual cleanup required | Handled automatically |
| Window Focus Refetch | Must implement manually | Built-in option |
| Background Updates | Must implement manually | Built-in (isFetching) |
| DevTools | None | Dedicated DevTools |
| Primary Use Case | Side effects/syncing | Server 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:
- 👨💻 TanStack Query Documentation
- 👨💻 React Official Docs - useEffect
- 👨💻 Reddit Discussion - Async Data in React
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments