I Was Wrong About useEffect for Data Fetching

By Odilon12 min read

I Was Wrong About useEffect for Data Fetching#

Three years ago I wrote a post about useEffect data fetching that I'm now partially embarrassed by. Not because it was wrong — the patterns were correct for the time — but because I was solving the wrong problem. Three thousand words explaining how to do data fetching in useEffect "properly" (cancellation tokens, race conditions, cleanup functions) when the real answer was: you shouldn't be fetching data in useEffect at all. Not for most cases anyway.

That post became the most-read piece on this blog. People still link to it. Every time they do, I want to add a banner at the top: "there's a better way now."

This is that better way.

The Pattern We All Wrote#

I don't need to explain this to you. You wrote it too:

tsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      });
    return () => { cancelled = true; };
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  return <ProfileCard user={user} />;
}

Three state variables. A cancellation boolean. The if (!cancelled) checks because StrictMode double-renders in dev. The waterfall when a child component does the same thing. The spinner on every navigation even when the data hasn't changed.

We collectively wrote this pattern thousands of times. Some of us (me) wrote blog posts teaching others to write it. And it worked. But it was always duct tape over a fundamental problem: React didn't have a first-class way to say "this component needs data before it can render."

Now it does.

The New Default#

With the App Router, here's the same thing:

tsx
// app/users/[id]/page.tsx — no "use client", no useState, no useEffect
export default async function UserProfilePage({
  params,
}: {
  params: { id: string };
}) {
  const user = await fetchUser(params.id);

  if (!user) {
    notFound();
  }

  return <ProfileCard user={user} />;
}

async function fetchUser(id: string) {
  const res = await fetch(`${process.env.API_BASE_URL}/users/${id}`, {
    next: { revalidate: 60 }, // cache for 60 seconds
  });
  if (!res.ok) return null;
  return res.json();
}

No state. No effect. No cancellation logic. The async function runs on the server, the HTML arrives with data already populated. If the component throws, the nearest error.tsx catches it. If the user doesn't exist, notFound() shows the right page.

The first time I wrote it, the reaction was: "there must be a catch, this is too simple." There isn't. Or at least, there hasn't been one in five months of production use.

The Fetch Cache#

Next.js extends the native fetch with caching options. If you're coming from Pages Router, here's the translation:

tsx
// Static — like getStaticProps
const data = await fetch(url);                            // cached indefinitely (default)
const data = await fetch(url, { cache: "force-cache" });  // same, explicit

// Dynamic — like getServerSideProps
const data = await fetch(url, { cache: "no-store" });     // never cached

// ISR — like revalidate in getStaticProps
const data = await fetch(url, { next: { revalidate: 300 } }); // revalidate every 5 minutes

And here's the thing that blew my mind: if multiple Server Components on the same page call fetch with the same URL, Next.js deduplicates the requests within a single render pass. You can co-locate data fetching in each component without worrying about duplicate network calls.

tsx
// Both components fetch the same user independently
// Next.js deduplicates — only one actual request

async function UserHeader({ userId }: { userId: string }) {
  const user = await fetch(`/api/users/${userId}`).then((r) => r.json());
  return <header>{user.name}</header>;
}

async function UserBio({ userId }: { userId: string }) {
  // yeah this looks redundant but it's actually fine
  const user = await fetch(`/api/users/${userId}`).then((r) => r.json());
  return <p>{user.bio}</p>;
}

This changes how you think about component composition. You stop hoisting all data fetching to a single parent and start letting each component fetch what it needs. The framework handles deduplication. It feels wrong at first if you've spent years optimizing request waterfalls, but it's actually correct.

On-Demand Revalidation#

Time-based revalidation covers the read side. For mutations, there's revalidatePath and revalidateTag:

tsx
// app/posts/[id]/actions.ts
"use server";

import { revalidatePath, revalidateTag } from "next/cache";
import { getMongoDb } from "@/lib/infrastructure/mongodb";

export async function publishPost(postId: string) {
  const db = await getMongoDb();

  await db.collection("posts").updateOne(
    { _id: postId },
    { $set: { status: "published", publishedAt: new Date() } }
  );

  revalidatePath(`/blog/${postId}`);
  revalidateTag("posts"); // invalidates anything fetched with tag "posts"
}

To use tags, you attach them when fetching:

tsx
const posts = await fetch("/api/posts", {
  next: { tags: ["posts"] },
}).then((r) => r.json());

Call revalidateTag("posts") and every cached fetch with that tag is invalidated. Next request gets fresh data.

Server Actions Replaced My API Routes#

This is the part that surprised me the most. Server Actions let you define server-side functions that forms call directly — no API route, no client-side fetch call, no custom endpoint.

tsx
// lib/actions/invoices.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getMongoDb } from "@/lib/infrastructure/mongodb";
import { auth } from "@/lib/auth";

const CreateInvoiceSchema = z.object({
  clientName: z.string().min(1, "Client name is required"),
  amount: z.number({ error: "Amount must be a number" }).positive(),
  dueDate: z.string().min(1, "Due date is required"),
});

export async function createInvoice(
  _prevState: { error: string | null },
  formData: FormData
): Promise<{ error: string | null }> {
  const session = await auth();
  if (!session) return { error: "Not authenticated" };

  const parsed = CreateInvoiceSchema.safeParse({
    clientName: formData.get("clientName"),
    amount: Number(formData.get("amount")),
    dueDate: formData.get("dueDate"),
  });

  if (!parsed.success) {
    return { error: parsed.error.issues[0].message };
  }

  const db = await getMongoDb();
  await db.collection("invoices").insertOne({
    ...parsed.data,
    organizationId: session.user.organizationId,
    status: "draft",
    createdAt: new Date(),
  });

  revalidatePath("/dashboard/invoices");
  redirect("/dashboard/invoices");
}
tsx
// The form component — client side, minimal
"use client";

import { useActionState } from "react";

export function CreateInvoiceForm({ action }) {
  const [state, formAction, isPending] = useActionState(action, { error: null });

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="clientName" className="block text-sm font-medium mb-1">
          Client Name
        </label>
        <input
          id="clientName"
          name="clientName"
          type="text"
          className="w-full rounded-md border px-3 py-2"
          required
        />
      </div>
      <div>
        <label htmlFor="amount" className="block text-sm font-medium mb-1">
          Amount
        </label>
        <input
          id="amount"
          name="amount"
          type="number"
          step="0.01"
          className="w-full rounded-md border px-3 py-2"
          required
        />
      </div>
      <div>
        <label htmlFor="dueDate" className="block text-sm font-medium mb-1">
          Due Date
        </label>
        <input
          id="dueDate"
          name="dueDate"
          type="date"
          className="w-full rounded-md border px-3 py-2"
          required
        />
      </div>
      {state.error && (
        <p className="rounded-md bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-700">
          {state.error}
        </p>
      )}
      <button
        type="submit"
        disabled={isPending}
        className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? "Creating..." : "Create Invoice"}
      </button>
    </form>
  );
}

This form works without JavaScript — the action attribute handles native form submission. When JS is available, React intercepts it. Progressive enhancement for free. Coming from the world of e.preventDefault() + fetch('/api/invoices', { method: 'POST' }), this feels almost too good.

Parallel Data Fetching#

One pattern I use constantly — fetch independent sources with Promise.all:

tsx
export default async function AccountDashboard({
  params,
}: {
  params: { accountId: string };
}) {
  // independent queries, run in parallel
  const [account, transactions, budget] = await Promise.all([
    fetchAccount(params.accountId),
    fetchTransactions(params.accountId, { limit: 20 }),
    fetchBudgetStatus(params.accountId),
  ]);

  return (
    <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
      <AccountSummary account={account} />
      <BudgetProgress budget={budget} />
      <RecentTransactions transactions={transactions} />
    </div>
  );
}

This was possible with getServerSideProps too. But with nested layouts and Suspense, each segment can fetch its own data in parallel without the parent having to know about or coordinate any of it. That's the real upgrade.

My Take#

The shift from useEffect-based fetching to Server Component fetching is the biggest quality-of-life improvement in React since hooks landed. The code is shorter, more direct, and handles cancellation, deduplication, and caching without writing a single line for them. The hard cases just disappear.

Here's how I divide things now: static or schedule-revalidated data lives in cached fetch calls in Server Components. Data that changes after user actions gets revalidated with revalidatePath/revalidateTag after Server Actions. Only genuinely real-time data (live dashboards, chat) still reaches for client-side subscriptions.

The useEffect patterns from my post three years ago aren't gone. You still need them for real-time updates, or outside of Next.js. But as the default for a Next.js application? Async Server Components are a clear upgrade. And I say that as someone who spent 3,000 words teaching the old way.

Sometimes being wrong feels pretty good.