Skip to main content
Why Fetching Data Inside useEffect Is Not a Good Idea
#React#useEffect#Data Fetching+1 more

Why Fetching Data Inside useEffect Is Not a Good Idea

D

Daniel Amekpoagbe

Author

February 11, 2026
4 min read

If you’ve ever dropped a fetch inside a useEffect and thought “this feels right,” welcome to the club. I did it for years. It was my default pattern.

But after enough production bugs, race conditions, and “why is this fetching twice?” moments, I realized it’s like trying to cook Banku(Ghanaian food) in a rice cooker: it technically works, but it’s messy, inefficient, and you’re left wondering why you didn’t just use the right tool.

Let’s break down why useEffect data fetching is almost always the wrong default in 2025+ React, and what you should be doing instead.

Why It Feels Natural (But Isn’t)

The mental model is seductive:

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

Component mounts → effect runs → data loads → UI updates. Simple, right?

The problem is that useEffect was never designed to be a data-fetching primitive. It’s a side-effect hook. Data fetching is a data problem, not a side-effect problem. Treating it as a side effect forces you to manually manage loading states, errors, caching, deduplication, retries, cancellation, and stale-while-revalidate — all things the framework (or a good library) should handle for you.

Problems You’ll Run Into

1. Double Fetching in Strict Mode (and it’s not a bug)

React 18+ Strict Mode intentionally mounts, unmounts, and remounts components in development to help you find bugs. That means your useEffect runs twice.

You’ll see two identical network requests in the Network tab. In production it only happens once… but now you’ve trained yourself to ignore duplicate requests, which is dangerous.

2. Race Conditions (the silent killer)

This is the one that bites everyone eventually:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);
}

User clicks “Profile A” → fetch starts. User quickly clicks “Profile B” → second fetch starts. Fetch A finishes last → setUser(A) overwrites the newer data.

You now have stale data on screen with no idea it happened. This is infuriatingly common in SPAs with fast navigation.

3. No Built-in Caching or Deduplication

Every time the component mounts (tab switch, route change, remount after error boundary, etc.), you refetch. Even if the data is identical and 2 seconds old.

You can hack around it with useRef flags or useMemo, but you’re now maintaining your own cache. That’s what libraries are for.

4. Cleanup Becomes a Nightmare

You eventually add this:

useEffect(() => {
  let isCancelled = false;
  const controller = new AbortController();

  fetch(url, { signal: controller.signal })
    .then(res => res.json())
    .then(data => {
      if (!isCancelled) setData(data);
    });

  return () => {
    isCancelled = true;
    controller.abort();
  };
}, [deps]);

You just wrote 15 lines of defensive code for something that should be one line. And you’ll still forget it sometimes.

What to Do Instead

Option 1: TanStack Query (React Query) – The Gold Standard

This is the library I reach for 99% of the time.

import { useQuery } from '@tanstack/react-query';

function Todos() {
  const { data, isPending, error, isFetching } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then(res => res.json()),
    staleTime: 5 * 60 * 1000,        // 5 minutes
    gcTime: 10 * 60 * 1000,          // keep in cache 10 minutes after unmount
    retry: 3,
  });

  if (isPending) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.map(todo => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  );
}

You instantly get:

  • Automatic deduplication (same key = one request)

  • Background refetching

  • Stale-while-revalidate

  • Mutations with optimistic updates

  • Devtools that show cache state

  • Infinite scrolling, pagination, etc. with almost no extra code

Once you use it, going back to manual useEffect feels like writing assembly.

Option 2: Next.js Server Components (or any meta-framework with RSC)

If you’re in Next.js  App Router:

// app/todos/page.tsx
async function getTodos() {
  const res = await fetch('https://jsonplaceholder.typicode.com/todos', {
    next: { revalidate: 3600 } // ISR - revalidate every hour
  });
  return res.json();
}

export default async function TodosPage() {
  const todos = await getTodos();

  return (
    <ul>
      {todos.slice(0, 10).map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Zero client-side JavaScript for the initial render. Instant content. SEO-friendly. Caching built into the framework. This is the future.

(You can still fetch on the client when you need interactivity with useQuery or the new use hook in React 19.)

A Better Analogy

Fetching in useEffect is like trying to heat your house by lighting a match inside every room. React Query is central heating. Next.js Server Components is living in a house that’s already warm when you walk in.

Conclusion

Yes, you can fetch data in useEffect. You shouldn’t — at least not as your primary strategy.

Modern React (and the ecosystem around it) has solved this problem so well that continuing to do it manually is like writing your own router in 2025.

Pick the right tool for the job:

  • TanStack Query → when you need rich client-side data management

  • Server Components / RSC → when you want the fastest possible initial render

  • use + Suspense (React 19) → for the bleeding edge

Switch once, and you’ll wonder how you ever lived without it.

Happy fetching (the right way).

Share this article: