Home / Articles

What useEffect Fetching Costs You and What TanStack Query Changes

Why effect-based data fetching leads to duplicate requests, boilerplate and stale closures in React, and how TanStack Query's keys, staleTime and invalidation fix them.

Publisher: REACTAPP.TOP6 min read1360 words

This article is published in English.

Sources: Stop Using useEffect for API Calls, Use React Query Instead

Fetching data inside useEffect is the first pattern most React developers learn, and it works fine for a single request on a single page. As an app grows, the same pattern quietly multiplies requests, repeats boilerplate in every component and invites subtle dependency bugs. This article explains where those costs come from and how TanStack Query (formerly React Query) addresses each one, so you can decide when to switch and what to configure once you do.

The pattern everyone starts with

The familiar shape is an effect with an empty dependency array that calls a fetch function when the component mounts:

useEffect(() => {
  fetchData();
}, []);

It reads as simple, and that is part of the problem. The code says nothing about caching, deduplication, retries, cancellation or what should happen when the component mounts again. Each of those gaps turns into a bug or an extra request once there are dozens of endpoints and more loading and error states to track.

Why the request fires twice, and the real issue behind it

A common first surprise is seeing the same request twice in the network tab on page load. In development, React's StrictMode deliberately mounts, unmounts and remounts components to surface effects that do not clean up after themselves. The duplicate request is intentional in development and does not happen in a production build.

The double call is a symptom of something that does matter in production: an effect-based fetch is bound to the component's lifecycle. Every mount triggers a new request, even when the data is already on hand. Components that mount repeatedly, such as tab panels, modals and list rows, keep hitting the backend, adding server load and making the UI feel slower than it needs to be.

TanStack Query separates the data from the component. Results are stored in a shared cache under a query key. When another component, or the same one after a remount, asks for a key whose data is still considered fresh, the library returns the cached value without touching the network. Multiple components requesting the same key at the same moment also share one in-flight request.

Hand-managed loading and error state

With useEffect, every request needs its own trio of state variables for the data, the loading flag and the error:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

Repeat that across every API call and the result is a lot of near-identical code, each copy with its own chance of a mistake. The classic one is forgetting to reset loading in the error path, leaving a spinner that never stops. Others include not ignoring a response that arrives after the component has unmounted, and letting a slow earlier response overwrite a newer one when inputs change quickly.

useQuery returns data, error, isLoading and isError (along with more detailed status fields) directly, and it handles out-of-order responses for you. The boilerplate disappears from components, and the edge cases are solved once in a well-tested library instead of many times in application code.

Dependency arrays versus query keys

Effects depend on getting the dependency array right. Leave out a value the effect reads and you get a stale closure that fetches with outdated parameters, a bug that can take hours to track down. Include an object or function that is recreated on every render and the effect runs on every render, sending a request each time.

TanStack Query replaces this guessing with a clearer contract: you give each query a key and a fetch function. The key should include every variable the request depends on, for example the resource name plus a user ID or filter. When the key changes, a new query runs; when it stays the same, cached data is reused. Keys are compared by their contents rather than by reference, so building a new array on each render does not cause extra fetches. Refetching is then driven by explicit policies, such as a component mounting, the window regaining focus or the network reconnecting, combined with the staleTime you configure.

You still have to think about dependencies, but you express them once in a key instead of maintaining an array that must stay in sync with the effect body.

Cache, staleTime and invalidation

Three features account for most of the difference in day-to-day work.

The cache

After a key's data is fetched, it stays in the cache. When the user navigates away and comes back, the cached result renders immediately, and if the data is stale the library refetches it in the background and updates the screen when the new response arrives.

staleTime

staleTime controls how long data counts as fresh. While it is fresh, remounts and new subscribers do not trigger a request at all. The default is zero, meaning data is treated as stale right away; cached data still renders instantly, but a background refetch happens on the next mount or window focus. Setting a sensible staleTime for data that does not change every second is often the single biggest reduction in backend traffic.

invalidateQueries

After a mutation, such as updating a profile or creating a post, you call queryClient.invalidateQueries with the related key. Matching queries are marked stale and any that are currently in use refetch automatically. There is no hand-written re-fetch after each mutation and no manual copying of server responses into local state.

Together, these three cover most of the pain that effect-based fetching creates. For a deeper look at organizing keys, query options and mutations at scale, see our guide to structuring a TanStack Query data layer.

A typical migration scenario

Consider a Next.js SaaS dashboard with several tabs, each loading its own data in a useEffect. Because switching tabs remounts the panels, every tab switch sends a fresh wave of requests. The backend receives far more traffic than necessary and users notice sluggish loading.

A migration usually follows three steps:

  1. Replace each effect with useQuery and a key that captures all request parameters.
  2. Give each query a staleTime that reflects how often its data really changes.
  3. After every mutation, invalidate the keys whose data it affects.

The expected outcome is a smoother dashboard, fewer backend calls and a better experience for users, with less code in each component.

Where useEffect still belongs

This is not a case for removing useEffect entirely. It remains the right tool for synchronizing with things outside React that are not request-and-response data: subscriptions, DOM event listeners, timers and third-party widgets. For API calls in an app that is growing, though, a dedicated server-state library is the better fit because it reduces backend load, removes boilerplate and provides caching and invalidation out of the box.

There are also cases where a library is more than you need, such as a small app with one or two requests. For those, a small custom hook with proper cancellation may be enough; our article on a minimal useFetch hook and its limits covers that trade-off. If you are unsure, migrate one page first and compare request counts in the network tab.

Key takeaways

  • The double request in development comes from StrictMode; the production issue is that effect-based fetches repeat on every mount.
  • A query key replaces the dependency array: include every input the request depends on, and caching and refetching follow from it.
  • The default staleTime is zero, so set it deliberately if you want to avoid background refetches on remount.
  • Invalidate related keys after mutations instead of manually re-fetching or syncing state.
  • Keep useEffect for synchronizing with external systems, not for loading server data.