Why I Stopped Using useEffect for Data Fetching (And What I Use Instead)

This article assumes React knowledge and targets practitioners.
If you've been writing React for more than six months, you've written this code. Probably more than once.
It works. It's familiar. It's in every tutorial from 2019 to 2023. It's also wrong for most use cases — and I'll show you exactly why.
The problems nobody talks about
Race conditions
Here's the bug you've probably shipped without knowing it. A user navigates to a profile page. The fetch starts. They click to a different profile before the first fetch completes. Now two fetches are in flight. Whichever one resolves last wins — even if it's the one for the profile they left.
The not so good fix for race conditions problem — an isCancelled flag or AbortController — adds additional ten lines and is easy to get wrong.
This is now twelve lines to do what should be only two. And most developers don't write it this way anyway.
No deduplication
If three components on the same page each render useEffect(() => fetch('/api/config'), []), you get three network requests for the same data. There's no coordination between component instances.
No caching
Every mount triggers a new fetch. Navigate away, come back — another fetch. There's no built-in way to say "if we fetched this data in the last 60 seconds, use it."
Waterfalls
When a parent component fetches and passes data to a child that also fetches, you've created a request waterfall. The second request can't start until the first resolves. useEffect makes waterfalls the default outcome rather than the exception.
What TanStack Query solves
TanStack Query (formerly React Query) is not "just a caching library." It's a server state manager — and server state is genuinely different from client state.
Client state: which modal is open, what the user has typed in an input field.
Server state: data that lives on a server, is shared across users, and can become stale.
Here's the same data fetch with TanStack Query:
Six lines. No useState. No useEffect. No cleanup function.
What you get automatically:
Deduplication. Three components that call useQuery({ queryKey: ['user', '1'] }) trigger one network request. The other two wait for the first to resolve.
Caching. The result is cached by queryKey. Navigate away, come back — the cached data shows instantly while a background refetch runs silently.
Race condition handling. Handled internally. You never write an isCancelled flag again.
Background refetching. When a user returns to a browser tab, stale data is refetched automatically.
Loading and error states. isLoading, isFetching, isError, error — all derived from the query state without managing it yourself.
What about React's use() hook?
If you're working in Next.js App Router or any setup that uses React Server Components, the answer looks different again.
In a Server Component, you don't need useEffect or TanStack Query at all:
You're awaiting the data directly. No hook, no loading state, no effect. The Suspense boundary above it handles the loading state.
For data that starts on the server but needs to be read in a Client Component, React 19's use() hook lets you pass a Promise as a prop and read it:
This is powerful but requires a Suspense boundary. It's the right tool when you want Server Component data fetching but need client interactivity on the result.
When should you still use useEffect for fetching?
Almost never for primary data loading. But there are legitimate edge cases:
Imperative integrations. Third-party SDKs that don't expose a Promise-based API and require you to register callbacks.
Subscriptions. WebSocket connections, EventSource streams, or real-time subscriptions where data arrives continuously rather than once.
Side effect triggered by user action. An analytics event or logging call that fires when something renders — though these are usually better placed in event handlers than effects.
If you find yourself writing useEffect(() => { fetch(...) }, []) for standard CRUD data loading in a component that doesn't need RSC, that's the pattern to replace.
The decision rule
Here's how I think about it:
- Next.js App Router, Server Components available? → Fetch directly in the Server Component with
async/await - Client Component that needs server data? → TanStack Query with
useQuery - Real-time data that updates continuously? →
useEffectwith proper cleanup and a WebSocket or SSE connection - Third-party SDK that requires
useEffect? → Use it, write the cleanup function, move on
The goal isn't to never use useEffect. It's to not use it as a fetch wrapper when better tools exist specifically for that job.
Migrating existing code
If you have a codebase full of useEffect data fetches, you don't need to rewrite everything at once. TanStack Query is additive — you can migrate one component at a time.
- Install:
pnpm add @tanstack/react-query - Wrap your app in
QueryClientProvider - Replace one
useEffectfetch withuseQuery - Delete the three
useStatecalls that went with it
The QueryClient handles caching globally, so the moment you migrate a component, it benefits from deduplication with any other component that uses the same queryKey.
The useEffect data fetch pattern exists because it worked before we had better options. TanStack Query has been production-ready since 2020. The App Router has been stable since 2023. The better options exist. Use them.
Up next: How to build a custom hook that actually earns its abstraction — the patterns that make custom hooks worth writing.
Related: React Server Components explained without the hype — the mental model that makes RSC click.
Alex Chen is a senior frontend engineer who writes about React patterns, JavaScript internals, and the decisions that separate maintainable codebases from ones that fight back. Opinionated by design.

