I Used useEffect for Data Fetching for 2 Years. The Waterfall Was Embarrassing.

typescript dev.to

When I finally ran a performance trace on my production app, I found four sequential API calls on page load. Each one starting only after the previous completed. Two seconds of blocked rendering before users saw anything useful.

Every one was a useEffect I'd written.

The waterfall I created

function UserDashboard({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

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

  if (!user) return <Spinner />;

  return (
    <div>
      <UserProfile user={user} />
      <UserPosts userId={userId} />   {/* fetches in its own useEffect */}
      <UserStats userId={userId} />   {/* fetches in its own useEffect */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

UserPosts and UserStats can't mount until user is loaded — they're inside if (!user) return <Spinner />. Each has its own useEffect that fires after mounting. So:

  1. UserDashboard mounts → fires fetch for user
  2. User data loads → render unblocks
  3. UserPosts mounts → fires fetch for posts
  4. UserStats mounts → fires fetch for stats

Sequential. Every time. Even though posts and stats have no dependency on user data — I just happened to put them below the null check.

Why useEffect causes this

useEffect fires after render. So: component renders (showing a spinner), then the effect fires, then data loads, then re-render with data. For a parent-child tree, this compounds — parent renders, fires its effect, gets data, re-renders — and only then do children render and fire their effects.

This isn't a React bug. It's using useEffect for a purpose it wasn't designed for.

What useEffect is actually for

useEffect synchronizes with external systems — things that happen because of render, not things that produce render data.

// Subscribing to events — correct useEffect use
useEffect(() => {
  const handler = () => setOnline(navigator.onLine);
  window.addEventListener('online', handler);
  window.addEventListener('offline', handler);
  return () => {
    window.removeEventListener('online', handler);
    window.removeEventListener('offline', handler);
  };
}, []);

// WebSocket — correct
useEffect(() => {
  const ws = new WebSocket(url);
  ws.onmessage = (e) => dispatch({ type: 'message', data: e.data });
  return () => ws.close();
}, [url]);
Enter fullscreen mode Exit fullscreen mode

Fetching server data that determines what a component displays isn't in this category. The effect runs after render — the component can't show the data on the first render. You're always at least one render behind.

The fix: Server Components

In Next.js App Router, Server Components run on the server and can await data directly:

export default async function UserDashboard({ params }: { params: { userId: string } }) {
  // Three fetches in parallel — resolved before HTML reaches the browser
  const [user, posts, stats] = await Promise.all([
    getUser(params.userId),
    getUserPosts(params.userId),
    getUserStats(params.userId),
  ]);

  return (
    <div>
      <UserProfile user={user} />
      <UserPosts posts={posts} />
      <UserStats stats={stats} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Zero client-side waterfall. The user gets a fully-rendered page.

If you're stuck on client components: React Query

function UserDashboard({ userId }: { userId: string }) {
  // These fire in parallel on mount — React Query doesn't waterfall
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  });

  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetch(`/api/users/${userId}/posts`).then(r => r.json()),
  });

  const { data: stats } = useQuery({
    queryKey: ['stats', userId],
    queryFn: () => fetch(`/api/users/${userId}/stats`).then(r => r.json()),
  });
}
Enter fullscreen mode Exit fullscreen mode

React Query fires all hooks simultaneously. Partial renders as each resolves instead of waiting for the slowest.

The AbortController you actually need

If you keep useEffect for fetches, always abort on cleanup:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/data/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

  return () => controller.abort();
}, [id]);
Enter fullscreen mode Exit fullscreen mode

Without this: if the component unmounts before the fetch completes, .then(setData) fires on an unmounted component. In older React versions, memory leak. In React 18+, a warning and wasted work.

The before/after

After refactoring my dashboard to Server Components with Promise.all:

  • Time to first meaningful paint: 2.1s → 0.8s
  • Sequential client-side network requests on load: 4 → 0
  • Lines of loading state management deleted: ~120

The waterfall was entirely self-inflicted. useEffect isn't wrong — I was using it for the wrong job.


If you're starting a Next.js project and want Server Components, React Query, and parallel data fetching configured from day one:

AI SaaS Starter Kit — whoffagents.com

More AI tools → whoffagents.com

Source: dev.to

arrow_back Back to Tutorials