Blog
Stop Fetching Data in useEffect: A Better Approach
Manual data fetching in useEffect quietly reinvents caching, deduplication, and error handling, so dedicated query libraries usually produce simpler and more correct code.
Almost every React tutorial teaches data fetching the same way: call fetch inside a useEffect, store the result in state, and render it. This works for a single request on a quiet page, but it falls apart as soon as real applications demand more. You need loading and error states, you need to avoid refetching data two components already loaded, you need to refetch when the user comes back to a tab, and you need to handle the request that finishes after the component has moved on. Each of these is a known problem with a known solution, and rebuilding them by hand in useEffect produces fragile, repetitive code. This article explains why the useEffect approach is harder than it looks and how purpose-built tools turn data fetching into a declarative, cache-aware concern that you configure rather than implement.
The useEffect Trap
The naive fetch-in-useEffect pattern has subtle bugs that bite in production. The most notorious is the race condition: if a component fetches based on some id, and that id changes before the first request resolves, both requests are in flight and whichever finishes last wins, which may not be the one you want. The fix requires a cleanup function that ignores stale responses, something most tutorials omit. You also have to manage three pieces of state manually, the data, the loading flag, and the error, and reset them correctly on every dependency change. None of this is shared across components, so two components needing the same data fire two requests. The effect also runs only in the component, so caching, retries, and background updates are all on you. The pattern is not wrong, but doing it correctly is far more work than it first appears.
What a Query Cache Does
Libraries like React Query and SWR introduce a cache keyed by a query identifier, and this single idea solves most of the hard problems at once. When two components request the same key, the library deduplicates them into one network call and shares the result. When a component unmounts and remounts, the cached data shows instantly while a fresh copy loads in the background. The cache tracks the status of each query, so you get loading, error, and success states for free without manual flags. It also tracks freshness, knowing which entries are stale and eligible for refetching. Because the cache lives outside any single component, the same data is available everywhere without prop drilling or lifting it into a global store. You describe what data you need with a key and a fetch function, and the cache handles the bookkeeping that you would otherwise write by hand repeatedly.
Stale-While-Revalidate Explained
The strategy that gives SWR its name and underpins React Query is stale-while-revalidate. The idea is to serve cached data immediately, even if it might be slightly out of date, while simultaneously fetching a fresh copy in the background and updating the screen when it arrives. This makes navigation feel instant because users see content right away instead of a spinner, yet the data stays current because every view triggers a quiet revalidation. You tune this with a staleness window: data considered fresh within that window is served without refetching, and only after it goes stale does interacting with it trigger a background update. This balances responsiveness against network traffic. Compared to fetching from scratch every time, it dramatically improves perceived performance, and compared to caching forever, it keeps the interface trustworthy. The pattern matches how most application data behaves, frequently read and occasionally changed.
Query Keys and Dependencies
The query key is the heart of the cache, and designing it well is most of using these libraries effectively. A key uniquely identifies a piece of cached data, and it should include every variable the fetch depends on. A list of posts for a given user might be keyed by the resource name and the user id together. When any part of the key changes, the library treats it as a different query, automatically fetching the new data and caching it separately, which elegantly handles things like pagination and filtering. This replaces the manual dependency array juggling of useEffect. Keys are also how you target cache operations later: you can invalidate everything matching a partial key to force a refetch after a mutation. Structuring keys consistently, often as arrays from general to specific, keeps invalidation predictable. Think of the key as the address of your data within the cache.
Mutations and Invalidation
Reading data is half the story; changing it is the other half. Mutations are writes such as creating, updating, or deleting a resource, and the challenge is keeping the cache consistent afterward. The standard approach is invalidation: after a successful mutation, you mark the relevant query keys as stale, and the library automatically refetches them so the screen reflects the new server truth. This is reliable because the source of truth remains the server. For a snappier feel, you can apply an optimistic update, immediately writing the expected result into the cache before the request completes, then rolling back if it fails. This makes actions feel instant but requires you to handle the rollback path carefully. The pairing of mutations with key invalidation gives you a clear mental model: writes go to the server, then you tell the cache which reads are now outdated.
Pagination and Infinite Lists
Long lists need either pages or infinite scroll, and both interact tricky with caching. With keyed pagination, each page is its own cache entry, so jumping between pages you already loaded is instant, but moving to a new page shows a loading state. The annoyance is that the list can flicker to empty between pages, which libraries solve with an option to keep showing the previous page's data until the next arrives, a much smoother experience. Infinite scrolling accumulates pages into one growing dataset, where the library tracks a cursor or page parameter and appends each new batch. The important detail is that the cache stores the whole accumulated structure under one key, so it can restore the full scrolled position on return. Both patterns benefit enormously from the cache because revisiting previously fetched ranges costs nothing, whereas a manual implementation would refetch each time.
Prefetching for Speed
Once data lives in a cache, you can populate it before the user actually needs it, which is one of the most effective perceived-performance techniques available. When a user hovers over a link or a row, you can prefetch the data for the destination so that by the time they click, it is already in the cache and the next view renders instantly with no spinner. The same idea applies to predictable next steps, like prefetching the second page of a list while the user reads the first. Because the cache deduplicates, prefetching is safe even if the data is also requested normally; the in-flight request is simply shared. This turns loading time into idle time spent waiting on user attention rather than network latency at the moment of the click. Manual fetching has no natural place to do this, but a cache makes prefetching a small, declarative addition.
When Manual Fetching Is Fine
None of this means you must install a library for every request. For a one-off fetch on a page that loads once and never needs revalidation, deduplication, or sharing, a carefully written useEffect with a cleanup function is perfectly reasonable, and avoiding a dependency keeps things simple. Server components and frameworks like Next.js also change the calculus by letting you fetch on the server during rendering, where caching happens at a different layer entirely and the client never runs the request. The judgment call is about how much of the caching machinery you actually need. If your fetch is isolated and static, manual is fine. The moment you find yourself writing deduplication, background refresh, retry logic, or sharing data across components, you are rebuilding a query library by hand, and that is the signal to adopt one instead.