Languages🇸🇦AR🇩🇪DE🇫🇷FR
Frontend ArchitectureJune 13, 2026·9 دقيقة قراءة·Miracle KaluMiracle Kalu

Stop Using useEffect for Data Fetching: A Senior Engineer's Guide to Server Components

A clean diagram showing data flowing from server to browser in a modern React application.

The useEffect Data Fetching Habit Is Hard to Break

When React Hooks landed, useEffect became the default place to fetch data. Tutorials taught it. Stack Overflow rewarded it. Every junior developer learned to put fetch inside a useEffect with an empty dependency array and a loading state. It worked. It was also the beginning of a long list of problems we are still cleaning up.

I have spent the last year moving a Next.js application from the Pages Router to the App Router. The biggest shift was not the file structure or the new routing conventions. It was learning to stop reaching for useEffect every time I needed data. Server Components change the default. They do not make client fetching obsolete, but they make it the exception rather than the rule.

This article is about when to fetch on the server, when you still need the client, and how to avoid the worst of both worlds.

What Goes Wrong with useEffect Fetching

Fetching inside useEffect looks simple until you try to make it robust. You need a loading state, an error state, cancellation logic, retry logic, and a way to keep the cache from going stale. Then you multiply that by every component that needs data. Before long, your application is a collection of tiny data managers that all behave slightly differently.

The bigger issues are architectural. Client-side fetching ships JavaScript to the browser, runs after the initial paint, blocks interactivity while the network request resolves, and creates waterfalls when parent components fetch before children can even start. It also couples your data source to your component tree, which makes testing and reuse harder than it needs to be.

There are legitimate use cases for client fetching. User-specific interactions, real-time updates, and anything that depends on browser-only APIs belong on the client. But the default data fetch for a page should almost never start in useEffect.

Server Components Change the Default

In the Next.js App Router, components are Server Components by default. They run on the server, can be async, and can fetch data directly at render time. The result is sent to the browser as HTML, which means the user sees content without waiting for JavaScript to boot up and make another round trip.

// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";

interface PostPageProps {
  params: Promise<{ slug: string }>;
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
      <PostBody body={post.body} />
    </article>
  );
}

This component is doing something that looks strange if you come from the Pages Router. It is awaiting data during render. There is no useState, no useEffect, and no loading spinner rendered by the component itself. The loading state is handled by loading.tsx in the same segment, and errors are handled by error.tsx. The component only knows how to turn data into markup.

The mental model shift is this: the component is not a little application that manages its own lifecycle. It is a function that transforms a request into HTML. Data fetching is part of that transformation, not a side effect of mounting.

The Caching Layer Is the Real Win

Server-side fetching in Next.js is not just about where the code runs. It is about the caching semantics that come with it. The fetch API in Server Components is extended with a cache option that lets you decide whether a request should be cached, how long it should live, and when it should be revalidated.

// Cached for the lifetime of the request by default
const posts = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 },
}).then((res) => res.json());

The next.revalidate option tells Next.js to cache the response at the Data Cache level for one hour. This is not the same as the browser cache. It is a server-side cache shared across requests, which means the upstream API only gets hit once per hour per unique URL, no matter how many users visit the page.

There are three caches you need to understand:

  • Data Cache: caches fetch responses across requests and deployments.
  • Router Cache: caches prefetched route segments in the browser for fast navigation.
  • Full Route Cache: caches the rendered HTML of static routes at build time or revalidation.

When these layers work together, you get pages that are fast on first visit and nearly instant on subsequent navigation. When they fight each other, you get confusing behavior where updates do not show up or stale data lingers longer than expected.

Where Suspense and Streaming Fit

Not every data fetch can be awaited before the page renders. Some data is slow, secondary, or depends on user interaction. That is where Suspense boundaries come in. You wrap the async part of the tree in a Suspense boundary and let Next.js stream the rest of the page to the browser while the slow data resolves.

import { Suspense } from "react";
import { PostContent } from "./PostContent";
import { RelatedArticles } from "./RelatedArticles";
import { RelatedSkeleton } from "./RelatedSkeleton";

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;

  return (
    <article>
      <PostContent slug={slug} />
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedArticles slug={slug} />
      </Suspense>
    </article>
  );
}

The key detail is that RelatedArticles is also a Server Component. It can be async and fetch its own data. The Suspense boundary gives you progressive rendering without moving the fetch to the client. The user sees the main content immediately and the related articles when they are ready.

This pattern is much harder to replicate cleanly with useEffect. You would need to coordinate loading states across multiple components, manage placeholders, and hope the browser does not paint an awkward sequence of spinners.

When Client Fetching Still Makes Sense

Server Components are not a religion. There are places where the client is the right place to fetch.

  • User interactions that change data: search as you type, filters, pagination, sorting. These belong in Client Components with a library like TanStack Query or SWR.
  • Real-time data: live dashboards, notifications, chat. The server cannot push updates to a static render.
  • Browser-only APIs: geolocation, clipboard, local storage, Bluetooth. If the data depends on the browser, fetch on the client.
  • Personalized data after hydration: user preferences, recently viewed items, A/B test assignments.

The rule of thumb is simple. If the data is needed to render the initial page, fetch it on the server. If the data is produced by an interaction or only exists in the browser, fetch it on the client.

Pagination with Query Parameters

One of the first questions teams ask when they move to Server Components is how to handle pagination. In the Pages Router, pagination usually lived in useState or useRouter. In the App Router, the URL is the source of truth, and Server Components receive searchParams as a prop.

// app/blog/page.tsx
interface BlogPageProps {
  searchParams: Promise<{ page?: string; limit?: string }>;
}

export default async function BlogPage({ searchParams }: BlogPageProps) {
  const { page = "1", limit = "10" } = await searchParams;
  const pageNum = Math.max(1, parseInt(page, 10) || 1);
  const limitNum = Math.min(50, Math.max(1, parseInt(limit, 10) || 10));

  const { posts, total } = await getPosts({ page: pageNum, limit: limitNum });
  const totalPages = Math.ceil(total / limitNum);

  return (
    <main>
      <PostGrid posts={posts} />
      <PaginationControls
        page={pageNum}
        totalPages={totalPages}
        limit={limitNum}
      />
    </main>
  );
}

The PaginationControls component is a Client Component because it handles clicks and updates the URL. It does not fetch data. It only builds links.

"use client";

import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link";

interface PaginationControlsProps {
  page: number;
  totalPages: number;
  limit: number;
}

export function PaginationControls({
  page,
  totalPages,
  limit,
}: PaginationControlsProps) {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const buildHref = (nextPage: number) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set("page", String(nextPage));
    params.set("limit", String(limit));
    return `${pathname}?${params.toString()}`;
  };

  return (
    <nav aria-label="Pagination">
      <Link
        href={buildHref(page - 1)}
        aria-disabled={page <= 1}
        className={page <= 1 ? "disabled" : ""}
      >
        Previous
      </Link>
      <span>
        Page {page} of {totalPages}
      </span>
      <Link
        href={buildHref(page + 1)}
        aria-disabled={page >= totalPages}
        className={page >= totalPages ? "disabled" : ""}
      >
        Next
      </Link>
    </nav>
  );
}

This pattern keeps data fetching on the server while letting the browser handle navigation. The URL is shareable, refreshable, and history-friendly. Because the fetch happens on the server, the cached page data benefits from the same fetch caching semantics as any other Server Component request.

The trickiest part is making sure the cache key reflects the query parameters. Next.js includes searchParams in the cache key automatically for route segments, but if you wrap your data fetch in unstable_cache or a custom cache function, you must include the parameters in the key yourself.

import { unstable_cache } from "next/cache";

const getPostsCached = unstable_cache(
  async (page: number, limit: number) => getPosts({ page, limit }),
  ["posts-list"],
  { revalidate: 60, tags: ["posts"] },
);

Note that unstable_cache uses the function arguments to build the key, so page and limit are already part of the cache identity. If you build your own cache, make sure you do the same. Forgetting to include pagination parameters in the cache key is a fast way to show page one on every page.

Handling Mutations and Revalidation

Fetching on the server is only half the story. You also need to update data. In the App Router, mutations are handled with Server Actions. A Server Action is an async function that runs on the server and can be called from a Client Component. After the action completes, you can revalidate the cache so the next render sees fresh data.

"use server";

import { revalidatePath } from "next/cache";

export async function publishComment(formData: FormData) {
  const rawFormData = {
    postId: formData.get("postId") as string,
    body: formData.get("body") as string,
  };

  await db.insert("comments", rawFormData);

  revalidatePath(`/blog/${rawFormData.postId}`);
}
// CommentForm.tsx
"use client";

import { publishComment } from "./actions";

export function CommentForm({ postId }: { postId: string }) {
  return (
    <form action={publishComment}>
      <input type="hidden" name="postId" value={postId} />
      <textarea name="body" required />
      <button type="submit">Post comment</button>
    </form>
  );
}

This pattern keeps the mutation logic on the server while letting the UI remain interactive. The cache invalidation is explicit. You decide which paths become stale and need to be re-rendered. That is a feature, not a bug. Implicit cache invalidation is how you end up with phantom data that nobody can explain.

The Migration Strategy That Actually Works

Moving from useEffect fetching to Server Components is not a big-bang rewrite. We migrated page by page, starting with the most static and highest-traffic routes. The pattern was always the same.

  1. Identify a page that fetches data in useEffect.
  2. Move the fetch into an async Server Component.
  3. Add or update loading.tsx and error.tsx for that segment.
  4. Move any client-only interactivity into smaller Client Components using "use client".
  5. Add Suspense boundaries around slow or non-critical data.
  6. Tune caching with revalidate, cache: "no-store", or dynamic rendering as needed.

The hardest part was unlearning the reflex to manage state. Engineers kept reaching for useState out of habit, even when there was no state to manage. Code review became the place where we asked: "Does this data need to be in the browser?" If the answer was no, it moved to the server.

Common Pitfalls

The first pitfall is treating Server Components as a replacement for API routes. They are not. Server Components render once per request and should not perform side effects like sending emails or writing to logs in a way that depends on render order. For actions and side effects, use Server Actions or API routes.

The second pitfall is over-caching. The default caching behavior is aggressive, which is great for performance and dangerous for data that changes frequently. We learned to be explicit about every fetch. If a request should not be cached, say so.

const data = await fetch("https://api.example.com/stats", {
  cache: "no-store",
});

The third pitfall is moving too much to the client. Every time we added "use client" to a component that was mostly presentational, we lost the benefits of server rendering for that subtree. We now keep Client Components as small and leaf-like as possible.

What We Measured

After migrating our highest-traffic pages, we saw three improvements. Time to First Byte dropped because the server could fetch data in parallel with rendering instead of waiting for the browser to hydrate first. Largest Contentful Paint improved because content arrived as HTML, not as a JavaScript payload that still needed to execute. And the JavaScript bundle size shrank because data fetching code stopped shipping to the client.

The qualitative improvement was just as important. Pages became easier to test because data dependencies were explicit function arguments instead of hidden inside hook chains. Components became easier to reuse because they no longer needed to know where their data came from. And debugging became simpler because there were fewer race conditions and cancellation bugs to chase.

Takeaways

  • useEffect is a side-effect manager, not a data fetching layer. Treating it as one creates loading, error, caching, and cancellation problems everywhere.
  • Fetch data on the server by default, especially for initial page renders. Server Components can be async and leverage Next.js caching layers.
  • Use loading.tsx, error.tsx, and Suspense boundaries for progressive rendering. Do not build loading states into every component.
  • Reserve client fetching for interactions, real-time updates, and browser-only data. Keep Client Components small and leaf-like.
  • Use Server Actions for mutations and invalidate caches explicitly. Implicit invalidation is the source of phantom data.
  • Migration is incremental. Start with static, high-traffic pages and move down the stack.
  • Be explicit about caching. Aggressive defaults are great until they hide stale data from you.

مشاركة:

XLinkedIn
Miracle Kalu

كتبه

Miracle Kalu

Senior Full Stack Engineer

أعجبك ما قرأته؟

أنا متاح لأدوار الهندسة الأولى والاستشارات التقنية. لنتحدث.

تواصل →

نُشر 13 يونيو 2026 · 9 دقيقة قراءة