Less useEffect: data fetching in modern React
For years, tutorials taught: “on mount, useEffect + fetch”. That pattern is easy to write and hard to get right at scale. Modern React and frameworks give you better primitives—if you match the tool to where data actually lives.
What goes wrong with fetch-in-effect
Timing and double invocation
useEffect runs after paint. Users see empty UI, then a flash of loading, then content. In React 18 Strict Mode (development), effects that set up subscriptions or fetch run twice on purpose to surface unsafe side effects. If your effect is not idempotent, you get duplicate requests or race conditions.
Dependency arrays
Omitting dependencies “fixes” stale data bugs by lying to the linter. Including the wrong dependencies causes infinite loops or redundant refetches. useEffect conflates synchronization with data loading, which are different concerns.
Waterfalls
Parent effect runs, then child mounts, then child effect runs. Without careful orchestration you build request waterfalls—each layer waits for the previous—where parallel fetching would have been faster.
Decision matrix (simplified)
| Constraint | Prefer |
|---|---|
| Data needed for first paint SEO / fast TTFB | Server Component or route loader |
| Shared cache, retries, background refresh on client | TanStack Query (or similar) |
| User gesture triggers one-off fetch | Event handler (onSubmit, onClick) |
| Subscribing to browser API or external stream | useEffect with cleanup |
Server Components (Next.js App Router)
If the tree can run on the server, async components and fetch with explicit caching replace a whole class of client effects:
export default async function Page() {
const res = await fetch('https://cms.example.com/page/home', {
next: { revalidate: 300 },
});
const data = await res.json();
return <HomeView data={data} />;
}
No useState for initial data, no “hydration mismatch” from missing skeleton discipline if you keep the async boundary predictable.
Client libraries: TanStack Query
For data that changes after mount, needs deduplication across components, or should refetch on focus/reconnect, client libraries encode the state machine:
isLoading/isFetching/error/dataqueryKeyfor cache identitystaleTimeto avoid hammering the API
const { data, error, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
staleTime: 60_000,
});
This replaces hand-rolled useEffect, useState, and useReducer for the common CRUD read path.
Event-driven fetch
Search-as-you-type, infinite scroll “load more”, and form submission should usually not use useEffect as the trigger. Tie fetches to the event that caused them; debounce in the handler or with a small helper.
When useEffect is still correct
Effects are for synchronizing React with an external system:
window.addEventListener('resize', …)with removal on cleanup.document.titleupdates based on props.- WebSocket subscription with
socket.close()in cleanup. - Imperative third-party widgets (maps, charts) that need DOM nodes.
For these, keep dependencies minimal and document why the effect exists.
React 19 and use
React 19 introduces use for reading promises and context in render (with Suspense). It does not remove the need for good data architecture, but it further blurs the line between “data in render” and effects. If you adopt it, ensure boundaries and error handling match your router’s streaming model.
Migration strategy for legacy code
- List every
useEffectthat callsfetch. - For each, classify: initial page data, user-triggered, or live subscription.
- Move initial data to the server where possible.
- Wrap remaining reads in Query with identical URLs and keys.
- Leave true side effects in
useEffectwith tests for cleanup.
Summary
useEffect is a lifecycle hook, not a data layer. Server rendering, dedicated client cache libraries, and event handlers cover most fetching needs with clearer semantics and fewer race conditions. Reserve useEffect for real side effects—and your future self will thank you in code review.