Why I Stopped Fetching APIs Directly Inside useEffect

February 12, 2026

When I first started learning React, every tutorial taught API calls the same way.

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

Seems reasonable.

Honestly, I wrote code like this for years.

There's nothing inherently wrong with it.

The problem isn't fetching.

The problem is everything that comes after fetching.

As applications grow, you slowly realize that fetching data is probably the easiest part.

Managing it is where all the complexity lives.

A Typical Component

Most applications start looking like this.

function Users() { const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetch("/users") .then(res => res.json()) .then(setUsers) .catch(setError) .finally(() => setLoading(false)); }, []); ... }

Looks perfectly fine.

Until you start asking questions.

Question #1

What happens if the request fails?

Should we retry automatically?

Retry after 1 second?

Retry only on network failures?

Show Retry button?

Every component now has to solve this problem.

Question #2

What if the user leaves the page before the request finishes?

Now the request is still running.

Eventually it resolves.

Your component no longer exists.

Without proper cleanup, you'll attempt to update state that nobody is using anymore.

One solution is introducing cleanup logic.

useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal, }); return () => controller.abort(); }, []);

Problem solved?

Not quite.

Question #3

What if the user opens the page...

Leaves...

Comes back five seconds later?

Should we really call the server again?

The data hasn't changed.

We're making the server do unnecessary work.

Multiply this by thousands of users and you've created load that never needed to exist.

This isn't only a frontend problem anymore.

It's a backend problem too.

Question #4

Should every visit show a loading spinner?

Imagine a user opens the Users page.

The application fetches the data from the server. A few seconds later, the user navigates to another page and then comes back almost immediately.

Should we really hit the server again?

Probably not.

In many cases, the data hasn't changed at all. Yet we're downloading the same response again, showing another loading spinner, and making the backend process another identical request.

A simple cache can completely avoid this.

if (cache.has(key)) { return cache.get(key); } const data = await fetch(...); cache.set(key, data);

Now, when the user revisits the page, we can serve the cached data instantly and decide in the background whether it actually needs to be refreshed.

The experience feels faster, the UI becomes more responsive, and we're no longer sending unnecessary requests to the server.

What About Search?

Now imagine the API depends on a search query.

A common implementation looks something like this:

useEffect(() => { fetchUsers(search); }, [search]);

At first glance, there's nothing wrong with it.

But think about what happens when someone types "react" into the search box.

Every keystroke updates the search state, which means the effect runs again.

The user types five characters...

r re rea reac react

...and we've already fired five network requests.

Only the last response is actually useful, but the server still has to process every request unless we do something about it.

Now we need to start thinking about things we never considered before:

  • Should we debounce user input?
  • What happens if an older request finishes after a newer one?
  • How do we cancel requests that are no longer needed?
  • How do we prevent stale responses from overwriting fresh data?

What started as a simple useEffect has suddenly turned into request lifecycle management.

Question #6

What if one row changes?

Imagine a table with 500 users.

User #231 changes their status.

Should we really refetch...

all 500 users?

Probably not.

Maybe we can update just one record.

Now we've entered the world of cache synchronization.

Building a Reusable Hook

At some point many developers think:

Let's create our own fetching hook.

const { data, loading, error } = useFetch("/users");

Much cleaner.

We've removed duplication.

But have we actually solved the hard problems?

Not really.

We still need:

  • retries
  • caching
  • request cancellation
  • stale data
  • background updates
  • deduplication
  • optimistic updates
  • cache invalidation

We're simply hiding complexity.

Caching Changes the Conversation

Now let's go back to one of the earlier questions.

What happens if a user visits a page, navigates somewhere else, and comes back a few seconds later?

Should we make another API request?

In many cases, the answer is no.

If the data hasn't changed, fetching it again only makes the user wait and puts unnecessary load on the server. Instead, we can keep the previous response in memory and reuse it while it's still considered fresh.

A very simplified implementation might look something like this:

if (cache.has(key)) { return cache.get(key); } const data = await fetch(...); cache.set(key, data);

Of course, a production-ready cache is much more sophisticated than this. You'll eventually need cache expiration, invalidation, background refetching, and synchronization across components. But the core idea remains the same.

By avoiding unnecessary requests, the application feels almost instant when users revisit a page. At the same time, we're reducing the number of requests reaching the backend, which helps both application performance and server scalability.

But Now Cache Becomes Another Problem

Questions start appearing again.

When should cache expire?

How much memory should we use?

What if another component changes the same data?

Should every component receive fresh data automatically?

How do we invalidate only one query?

Now we're building...

a query library.

Enter React Query

Libraries like TanStack Query already solved these problems.

const { data } = useQuery({ queryKey: ["users"], queryFn: fetchUsers, staleTime: 5 * 60 * 1000, });

Looks simple.

But under the hood it gives us:

  • Request deduplication
  • Automatic caching
  • Retries
  • Background refetching
  • Cache invalidation
  • Window focus synchronization
  • Loading states
  • Error states
  • Optimistic updates
  • Request cancellation

Years of engineering...

inside one hook.

You're Not Just Fetching Data

This realization completely changed how I think about frontend development.

Whenever I build a feature today, I ask questions before writing any code.

How often does this data change?

Every second?

Every minute?

Once a day?

Never?

Does it actually need a loading spinner?

Or can we show cached data first?

Can this be updated optimistically?

Can users see the update immediately while the server processes it?

Is this Server State or Client State?

This distinction is incredibly important.

Server State

  • Users
  • Orders
  • Products
  • Notifications
  • Reports

Client State

  • Theme
  • Sidebar open
  • Selected row
  • Modal visibility
  • Current tab

Treating these two kinds of state the same usually leads to unnecessary complexity.

You're Also Helping the Backend Team

One lesson I learned over time is that frontend engineers have a huge impact on backend scalability.

Good frontend architecture can dramatically reduce server load.

Imagine 10,000 users opening the same page.

Without caching:

10,000 requests.

With proper caching:

Maybe only a fraction of those requests ever reach the server.

Even something as small as an unstable dependency array can accidentally trigger dozens of unnecessary requests.

Multiply that across thousands of users and you've unintentionally created traffic your backend never needed to handle.

Performance isn't only about making the UI faster.

It's also about being a good citizen of the entire system.

Final Thoughts

Today, I rarely think of data fetching as "calling an API."

Instead, I think about managing server state.

Fetching is easy.

Designing a data flow that's resilient, cache-friendly, scalable, and pleasant for users is where engineering begins.

That's also why I no longer reach for useEffect by default.

Not because it's wrong.

But because modern applications usually need much more than simply making a request.

GitHub
LinkedIn