Frontend-ArchitekturJune 13, 2026·8 Min. Lesezeit·Miracle KaluMiracle Kalu

Hör auf, Daten in useEffect abzurufen: Ein Leitfaden für Senior Engineers zu Server Components

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

Die Gewohnheit, Daten in useEffect abzufragen, ist schwer abzulegen

Als React Hooks eingeführt wurden, wurde useEffect zum Standardort für Datenabfragen. Tutorials lehrten es. Stack Overflow belohnte es. Jeder Junior-Entwickler lernte, fetch in ein useEffect mit einem leeren Dependency-Array und einem Loading-State zu packen. Es funktionierte. Es war auch der Beginn einer langen Liste von Problemen, die wir noch immer aufräumen.

Ich habe das letzte Jahr damit verbracht, eine Next.js-Anwendung vom Pages Router zum App Router zu migrieren. Der größte Wandel war nicht die Dateistruktur oder die neuen Routing-Konventionen. Es war das Umlernen, bei jedem Datenbedarf nicht reflexartig zu useEffect zu greifen. Server Components ändern den Standard. Sie machen clientseitiges Fetchen nicht obsolet, aber zur Ausnahme statt zur Regel.

Dieser Artikel handelt davon, wann man auf dem Server abruft, wann man den Client immer noch braucht und wie man das Schlimmste beider Welten vermeidet.

Was an useEffect-Fetching schiefgeht

Das Abrufen in useEffect sieht einfach aus, bis man versucht, es robust zu machen. Man braucht einen Loading-State, einen Error-State, Abbruchlogik, Retry-Logik und eine Möglichkeit, den Cache nicht veralten zu lassen. Dann multipliziert man das mit jeder Komponente, die Daten benötigt. Schon bald ist die Anwendung eine Sammlung kleiner Datenmanager, die sich alle leicht anders verhalten.

Die größeren Probleme sind architektonischer Natur. Clientseitiges Fetching liefert JavaScript an den Browser, läuft nach dem initialen Paint, blockiert die Interaktivität, während die Netzwerkanfrage läuft, und erzeugt Waterfalls, wenn Elternkomponenten abrufen müssen, bevor Kinder überhaupt starten können. Es koppelt auch die Datenquelle an den Komponentenbaum, was Tests und Wiederverwendung erschwert.

Es gibt berechtigte Anwendungsfälle für clientseitiges Fetchen. Benutzerspezifische Interaktionen, Echtzeit-Updates und alles, was von Browser-only-APIs abhängt, gehört auf den Client. Aber der Standarddatenabruf für eine Seite sollte fast nie in useEffect starten.

Server Components ändern den Standard

Im Next.js App Router sind Komponenten standardmäßig Server Components. Sie laufen auf dem Server, können async sein und können Daten direkt zum Renderzeitpunkt abrufen. Das Ergebnis wird als HTML an den Browser gesendet, sodass der Nutzer Inhalte sieht, ohne darauf zu warten, dass JavaScript bootet und eine weitere Runde dreht.

// 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>
  );
}

Diese Komponente macht etwas, das seltsam aussieht, wenn man vom Pages Router kommt. Sie wartet während des Renderns auf Daten. Es gibt kein useState, kein useEffect und keinen Loading-Spinner, der von der Komponente selbst gerendert wird. Der Loading-State wird durch loading.tsx im selben Segment behandelt, Fehler durch error.tsx. Die Komponente weiß nur, wie sie Daten in Markup verwandelt.

Der mentale Modellwechsel ist folgender: Die Komponente ist keine kleine Anwendung, die ihren eigenen Lebenszyklus verwaltet. Sie ist eine Funktion, die eine Anfrage in HTML transformiert. Datenabruf ist Teil dieser Transformation, kein Nebeneffekt des Mountings.

Die Caching-Schicht ist der wahre Gewinn

Serverseitiges Fetching in Next.js geht nicht nur darum, wo der Code läuft. Es geht um die Caching-Semantik, die damit einhergeht. Die fetch-API in Server Components ist um eine cache-Option erweitert, mit der man entscheiden kann, ob eine Anfrage gecacht werden soll, wie lange sie gültig bleibt und wann sie revalidiert werden soll.

// Standardmäßig für die Lebensdauer der Anfrage gecacht
const posts = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 },
}).then((res) => res.json());

Die Option next.revalidate sagt Next.js, die Antwort auf Data-Cache-Ebene für eine Stunde zu cachen. Das ist nicht dasselbe wie der Browser-Cache. Es ist ein serverseitiger Cache, der über Anfragen hinweg geteilt wird, was bedeutet, dass die Upstream-API nur einmal pro Stunde und eindeutiger URL aufgerufen wird, egal wie viele Nutzer die Seite besuchen.

Es gibt drei Caches, die man verstehen muss:

  • Data Cache: cacht fetch-Antworten über Anfragen und Deployments hinweg.
  • Router Cache: cacht vorgeholte Routensegmente im Browser für schnelle Navigation.
  • Full Route Cache: cacht das gerenderte HTML statischer Routen zur Build-Zeit oder nach Revalidierung.

Wenn diese Schichten zusammenarbeiten, erhält man Seiten, die beim ersten Besuch schnell und bei späterer Navigation nahezu instantan sind. Wenn sie sich gegenseitig bekämpfen, zeigt sich verwirrendes Verhalten: Updates erscheinen nicht, oder veraltete Daten bleiben länger als erwartet bestehen.

Wo Suspense und Streaming passen

Nicht jeder Datenabruf kann vor dem Rendern der Seite abgewartet werden. Manche Daten sind langsam, sekundär oder hängen von Benutzerinteraktionen ab. Hier kommen Suspense-Boundaries ins Spiel. Man wickelt den asynchronen Teil des Baums in eine Suspense-Boundary und lässt Next.js den Rest der Seite an den Browser streamen, während die langsamen Daten auflösen.

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>
  );
}

Das entscheidende Detail ist, dass RelatedArticles ebenfalls eine Server Component ist. Sie kann async sein und ihre eigenen Daten abrufen. Die Suspense-Boundary ermöglicht progressives Rendering, ohne den Abruf auf den Client zu verlagern. Der Nutzer sieht den Hauptinhalt sofort und die verwandten Artikel, wenn sie bereit sind.

Dieses Muster ist mit useEffect sauber viel schwerer nachzubilden. Man müsste Loading-States über mehrere Komponenten koordinieren, Platzhalter verwalten und hoffen, dass der Browser keine unangenehme Abfolge von Spinners zeichnet.

Wann clientseitiges Fetching immer noch Sinn macht

Server Components sind keine Religion. Es gibt Stellen, an denen der Client der richtige Ort zum Abrufen ist.

  • Benutzerinteraktionen, die Daten ändern: Suche während der Eingabe, Filter, Paginierung, Sortierung. Diese gehören in Client Components mit einer Bibliothek wie TanStack Query oder SWR.
  • Echtzeitdaten: Live-Dashboards, Benachrichtigungen, Chat. Der Server kann keine Updates an ein statisches Render pushen.
  • Nur-Browser-APIs: Geolocation, Zwischenablage, Local Storage, Bluetooth. Wenn die Daten vom Browser abhängen, auf dem Client abrufen.
  • Personalisierte Daten nach Hydration: Benutzereinstellungen, kürzlich angesehene Artikel, A/B-Test-Zuordnungen.

Die Faustregel ist einfach. Wenn die Daten für das initiale Rendern der Seite benötigt werden, auf dem Server abrufen. Wenn die Daten durch eine Interaktion entstehen oder nur im Browser existieren, auf dem Client abrufen.

Paginierung mit Query-Parametern

Eine der ersten Fragen, die Teams beim Umstieg auf Server Components stellen, ist, wie man Paginierung handhabt. Im Pages Router lebte die Paginierung meist in useState oder useRouter. Im App Router ist die URL die Quelle der Wahrheit, und Server Components erhalten searchParams als 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>
  );
}

Die PaginationControls-Komponente ist eine Client Component, weil sie Klicks verarbeitet und die URL aktualisiert. Sie ruft keine Daten ab. Sie baut nur 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" : ""}
      >
        Vorherige
      </Link>
      <span>
        Seite {page} von {totalPages}
      </span>
      <Link
        href={buildHref(page + 1)}
        aria-disabled={page >= totalPages}
        className={page >= totalPages ? "disabled" : ""}
      >
        Nächste
      </Link>
    </nav>
  );
}

Dieses Muster hält den Datenabruf auf dem Server und lässt den Browser die Navigation übernehmen. Die URL ist teilbar, aktualisierbar und historiefreundlich. Da der Abruf auf dem Server stattfindet, profitieren die zwischengespeicherten Seitendaten von denselben fetch-Caching-Semantiken wie jede andere Server-Component-Anfrage.

Der kniffligste Teil ist sicherzustellen, dass der Cache-Key die Query-Parameter widerspiegelt. Next.js berücksichtigt searchParams automatisch im Cache-Key für Routensegmente. Aber wenn man den Datenabruf in unstable_cache oder einer eigenen Cache-Funktion verpackt, muss man die Parameter selbst in den Key aufnehmen.

import { unstable_cache } from "next/cache";

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

Beachte, dass unstable_cache die Funktionsargumente verwendet, um den Key zu bilden, sodass page und limit bereits Teil der Cache-Identität sind. Wenn man seinen eigenen Cache baut, muss man dasselbe tun. Die Paginierungsparameter im Cache-Key zu vergessen, ist der schnellste Weg, auf jeder Seite Seite eins anzuzeigen.

Mutationen und Revalidierung

Serverseitiges Fetchen ist nur die halbe Geschichte. Man muss auch Daten aktualisieren. Im App Router werden Mutationen mit Server Actions gehandhabt. Eine Server Action ist eine asynchrone Funktion, die auf dem Server läuft und aus einer Client Component aufgerufen werden kann. Nachdem die Aktion abgeschlossen ist, kann man den Cache revalidieren, damit das nächste Render frische Daten sieht.

"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">Kommentar posten</button>
    </form>
  );
}

Dieses Muster hält die Mutationslogik auf dem Server, während die UI interaktiv bleibt. Die Cache-Invalidierung ist explizit. Man entscheidet, welche Pfade veraltet sind und neu gerendert werden müssen. Das ist ein Feature, kein Bug. Implizite Cache-Invalidierung ist der Weg zu Phantomdaten, die niemand erklären kann.

Die Migrationsstrategie, die tatsächlich funktioniert

Der Umstieg von useEffect-Fetching zu Server Components ist kein Big-Bang-Rewrite. Wir migrierten Seite für Seite, beginnend mit den statischsten und am stärksten frequentierten Routen. Das Muster war immer dasselbe.

  1. Eine Seite identifizieren, die Daten in useEffect abruft.
  2. Den Abruf in eine async Server Component verschieben.
  3. loading.tsx und error.tsx für das Segment hinzufügen oder aktualisieren.
  4. Client-only-Interaktivität in kleinere Client Components mit "use client" verschieben.
  5. Suspense-Boundaries um langsame oder nicht kritische Daten legen.
  6. Caching mit revalidate, cache: "no-store" oder dynamischem Rendering nach Bedarf anpassen.

Der schwierigste Teil war das Abgewöhnen des Reflexes, State zu verwalten. Entwickler griffen aus Gewohnheit zu useState, auch wenn es keinen State zu verwalten gab. Die Code Review wurde zum Ort, an dem wir fragten: "Braucht diese Daten den Browser?" War die Antwort nein, wanderten sie auf den Server.

Häufige Fallstricke

Der erste Fallstrick ist es, Server Components als Ersatz für API-Routen zu betrachten. Sie sind das nicht. Server Components rendern einmal pro Anfrage und sollten keine Seiteneffekte wie E-Mail-Versand oder Logging auf eine Weise ausführen, die von der Render-Reihenfolge abhängt. Für Aktionen und Seiteneffekte verwendet man Server Actions oder API-Routen.

Der zweite Fallstrick ist übermäßiges Caching. Das Standard-Caching-Verhalten ist aggressiv, was großartig für die Performance und gefährlich für häufig wechselnde Daten ist. Wir lernten, bei jedem Fetch explizit zu sein. Wenn eine Anfrage nicht gecacht werden soll, sagt man das.

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

Der dritte Fallstrick ist, zu viel auf den Client zu verlagern. Jedes Mal, wenn wir "use client" zu einer Komponente hinzufügten, die überwiegend präsentational war, verloren wir die Vorteile des Server Renderings für diesen Teilbaum. Wir halten Client Components jetzt so klein und blattartig wie möglich.

Was wir gemessen haben

Nach der Migration unserer am stärksten frequentierten Seiten sahen wir drei Verbesserungen. Die Time to First Byte sank, weil der Server Daten parallel zum Rendern abrufen konnte, anstatt darauf zu warten, dass der Browser zuerst hydratisiert. Der Largest Contentful Paint verbesserte sich, weil Inhalte als HTML ankamen, nicht als JavaScript-Payload, die noch ausgeführt werden musste. Und die JavaScript-Bundlegröße schrumpfte, weil Fetching-Code nicht mehr an den Client ausgeliefert wurde.

Die qualitative Verbesserung war ebenso wichtig. Seiten wurden leichter testbar, weil Datenabhängkeiten explizite Funktionsargumente waren statt in Hook-Ketten versteckt. Komponenten wurden leichter wiederverwendbar, weil sie nicht mehr wissen mussten, woher ihre Daten kamen. Und das Debuggen wurde einfacher, weil es weniger Race Conditions und Abbruch-Bugs zu jagen gab.

Takeaways

  • useEffect ist ein Side-Effect-Manager, keine Datenabrufschicht. Ihn so zu behandeln, erzeugt überall Loading-, Error-, Caching- und Abbruchprobleme.
  • Hole Daten standardmäßig auf dem Server, besonders für initiales Seitenrendering. Server Components können async sein und die Caching-Schichten von Next.js nutzen.
  • Verwende loading.tsx, error.tsx und Suspense-Boundaries für progressives Rendering. Baue keine Loading-States in jede Komponente ein.
  • Reserviere clientseitiges Fetchen für Interaktionen, Echtzeit-Updates und Browser-only-Daten. Halte Client Components klein und blattartig.
  • Verwende Server Actions für Mutationen und invalidiere Caches explizit. Implizite Invalidierung ist die Quelle von Phantomdaten.
  • Migration ist inkrementell. Beginne mit statischen, stark frequentierten Seiten und arbeite dich nach unten.
  • Sei explizit beim Caching. Aggressive Defaults sind großartig, bis sie veraltete Daten vor dir verbergen.

Teilen:

XLinkedIn
Miracle Kalu

Verfasst von

Miracle Kalu

Senior Full Stack Engineer

Hat dir das Gefallen?

Ich bin verfügbar für Senior-Engineering-Rollen und technische Beratung. Lass uns reden.

Kontakt aufnehmen →

Veröffentlicht 13. Juni 2026 · 8 Min. Lesezeit