post

React Server Components und Server Actions: Die neue Grenze

Server Components laufen nur auf dem Server und schicken ihr Ergebnis statt ihres Codes in den Browser, Server Actions holen das Schreiben zurück auf den Server – zusammen verschieben sie die Grenze zwischen Client und Server neu.

React Server Components und Server Actions: Die neue Grenze

Im React-Überblick habe ich Server Components bewusst nur als Wegweiser hingestellt – als den Rand, an dem das Rendern nicht mehr im Browser endet, sondern schon auf dem Server beginnt. Ich habe damals gesagt: Das ist ein eigenes großes Thema, und es verschiebt etwas Grundlegendes. Diesen Beitrag schulde ich seitdem. Denn wenn man einmal verstanden hat, was Server Components wirklich verschieben, sieht man React ein Stück weit anders.

Die kürzeste Zusammenfassung, die ich in Schulungen gebe, lautet: Server Components und Server Actions sind keine neue Rendering-Optimierung. Sie sind eine Ortsentscheidung. Nicht „wie rendere ich schneller“, sondern „welcher Teil der Oberfläche bleibt auf dem Server und welcher geht in den Browser“. Das klingt klein, ist aber der Grund, warum so viele Leute an diesem Thema zunächst hängenbleiben – man sucht nach einem neuen Hook und findet stattdessen eine verschobene Grenze.

Was eine Server Component wirklich ist

Eine Server Component läuft nur auf dem Server. Sie wird dort ausgeführt, rendert dort – mit direktem Zugriff auf Datenbank, Dateisystem oder interne Services – und schickt anschließend nur ihr Ergebnis an den Browser. Nicht ihren Code. Das ist der entscheidende Satz. Für eine Komponente, die bloß etwas anzeigt, landet am Ende null JavaScript im Client-Bundle. Der Browser bekommt eine fertige Beschreibung dessen, was er darstellen soll, und nicht die Komponente, die diese Beschreibung erzeugt hat.

Das ist ein anderer Umgang mit dem Wort „rendern“, als man ihn gewohnt ist. Deshalb lohnt sich der Rückgriff auf einen früheren Beitrag: In Wie React den Baum rendert habe ich gezeigt, dass Rendern bei React ohnehin nur eine Berechnung ist – eine Funktion, die aus Eingaben eine Beschreibung des Baums macht, die React dann mit dem letzten Stand abgleicht. Wenn Rendern aber nur eine Berechnung ist, dann stellt sich sofort die Frage: Wo läuft diese Berechnung? Genau hier setzen Server Components an. Sie verlagern einen Teil dieser Berechnung dauerhaft auf den Server, statt ihn wie bisher im Browser zu erledigen.

Der Unterschied zu SSR, den fast alle verwechseln

An dieser Stelle muss ich einen Punkt geraderücken, weil er in jeder zweiten Schulung für Verwirrung sorgt. Server Components sind nicht dasselbe wie Server-Side Rendering, und der Unterschied ist nicht kosmetisch.

Klassisches SSR nimmt ganz normale Client-Komponenten und rendert sie einmal auf dem Server zu HTML, damit der Browser sofort etwas sieht. Danach schickt der Server aber trotzdem den Komponenten-Code hinterher, und im Browser läuft die Hydratisierung: React hängt sich an das gelieferte HTML, spielt dieselbe Komponente noch einmal durch und macht sie interaktiv. Der Code geht also mit – SSR ist ein früher erster Eindruck, kein Sparen von JavaScript. Die Komponente existiert am Ende doppelt: einmal als HTML vom Server, einmal als lauffähiger Code im Browser.

Eine Server Component existiert dagegen nie im Client-Bundle. Es gibt nichts zu hydratisieren, weil es im Browser keine zweite Ausführung gibt. Der Server hat gerechnet, das Ergebnis ist da, und damit endet die Geschichte für diesen Teil des Baums. SSR beantwortet die Frage „wann sieht der Nutzer zum ersten Mal etwas“. Server Components beantworten die andere Frage: „muss dieser Code überhaupt jemals in den Browser“. Für reine Anzeige ist die Antwort jetzt nein.

flowchart TB
  subgraph Server
    SC["Server Component<br/>liest Daten direkt"]
  end
  subgraph Browser
    UI["fertiges Ergebnis<br/>wird angezeigt"]
    Island["Client-Insel<br/>(Hooks, Events)"]
  end
  DB[("Datenbank / Service")]
  SC -->|await| DB
  SC -->|nur Ergebnis,<br/>kein Code| UI
  SC -.->|bettet ein| Island

Die Grenze heißt "use client"

Wenn Server Components der Normalfall sind, braucht man eine Markierung für die Ausnahme – für die Stelle, an der Interaktivität beginnt. Diese Markierung ist die Direktive "use client" am Anfang einer Datei. Alles oberhalb dieser Grenze läuft auf dem Server: Daten laden, anzeigen, Struktur bauen, kein State. Ab der Direktive ist man im Browser-Land mit allem, was dazugehört – useState, useEffect, Event-Handler, Zugriff auf window.

Wichtig ist die Richtung, in der diese beiden Welten zusammenpassen. Eine Server Component darf Client Components als Kinder enthalten – man streut Inseln der Interaktivität in eine ansonsten statische, serverseitig gerenderte Seite. Der umgekehrte Weg ist eingeschränkt: Eine Client Component kann eine Server Component nicht einfach importieren, weil der Server-Code dann ja doch im Browser landen müsste. Sie kann eine Server Component nur als übergebenen children-Slot einbetten – der Server rendert das Kind, die Client-Komponente bekommt das fertige Ergebnis gereicht und packt ihr interaktives Drumherum darum. Diese eine Regel erklärt die meisten Fehlermeldungen, über die Leute am Anfang stolpern.

flowchart TB
  Page["Server Component (Seite)<br/>lädt Daten, zeigt an"]
  Page --> List["Server Component<br/>Rechnungsliste"]
  Page --> Button["Client-Insel<br/>&quot;use client&quot;: Button mit onClick"]
  Button -->|darf als children| Slot["Server-Inhalt<br/>als Slot"]

Server Components konkret

Weil eine Server Component nur auf dem Server läuft, darf sie etwas, das im Browser nie ging: Sie darf async sein und im Body direkt await nutzen. Das Laden von Daten passiert dann ohne useEffect, ohne Lade-Flag, ohne den ganzen Apparat – man schreibt es hin, als wäre es eine normale Funktion, die eben auf eine Datenbank zugreifen kann.

// invoices-page.jsx — a Server Component, no "use client" here
import { db } from "@/lib/db";
import { PayButton } from "./pay-button";

export default async function InvoicesPage() {
  const invoices = await db.invoice.findMany({ where: { open: true } });

  return (
    <section>
      <h1>Open invoices: {invoices.length}</h1>
      <ul>
        {invoices.map((invoice) => (
          <li key={invoice.id}>
            {invoice.title} — {invoice.amount} €
            <PayButton invoiceId={invoice.id} />
          </li>
        ))}
      </ul>
    </section>
  );
}

Zwei Dinge stehen hier bewusst nebeneinander. Der await db.invoice.findMany(...) greift direkt auf die Datenbank zu – dieser Code verlässt den Server nie, es gibt keine API-Route dazwischen und keinen Ladezustand im Browser. Und PayButton ist eine eingebettete Client-Insel: alles, was einen Klick braucht, wandert in eine eigene Datei mit "use client".

// pay-button.jsx
"use client";

import { useState } from "react";

export function PayButton({ invoiceId }) {
  const [busy, setBusy] = useState(false);
  return (
    <button disabled={busy} onClick={() => setBusy(true)}>
      {busy ? "Paying…" : "Pay"}
    </button>
  );
}

Was in einer Server Component nicht geht, ist ebenso lehrreich: kein useState, kein useEffect. Auf dem Server gibt es keinen Client-Zustand, den man über die Zeit hält, und es gibt kein erneutes Rendern nach einem Klick. Der Server rechnet einmal und ist fertig. Alles, was sich über die Zeit ändert, weil ein Mensch damit interagiert, gehört in eine Insel jenseits der Grenze.

Server Actions: das Schreiben zurück auf den Server

Server Components lösen das Lesen und Anzeigen. Für das Schreiben gibt es das Gegenstück: Server Actions, markiert mit der Direktive "use server". Das sind Funktionen, die auf dem Server laufen, aber vom Client aus aufgerufen werden – am direktesten als action eines <form>. Man baut also keine API-Route mehr von Hand, definiert kein Request-Format und keinen Endpunkt. Man schreibt eine Funktion, die Daten verändert, markiert sie, und React kümmert sich um den Weg dazwischen.

// invoice-actions.js
"use server";

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function payInvoice(formData) {
  const id = formData.get("invoiceId");
  await db.invoice.update({ where: { id }, data: { open: false } });
  revalidatePath("/invoices");
}

Aufgerufen wird das direkt aus dem Formular. Damit der Nutzer während der laufenden Aktion Rückmeldung bekommt, gibt es zwei Hooks, die genau dafür da sind: useActionState bündelt den Aufruf mit seinem Ergebnis und einem Pending-Flag, useFormStatus liest den Pending-Zustand des umgebenden Formulars in einem Kind-Element aus.

// pay-form.jsx
"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { payInvoice } from "./invoice-actions";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Paying…" : "Pay invoice"}
    </button>
  );
}

export function PayForm({ invoiceId }) {
  const [state, formAction] = useActionState(payInvoice, null);
  return (
    <form action={formAction}>
      <input type="hidden" name="invoiceId" value={invoiceId} />
      <SubmitButton />
    </form>
  );
}

Der Reiz liegt darin, wie wenig hier steht. Kein fetch, kein manuelles POST, kein selbstgebauter Endpunkt, keine Serialisierung. Man ruft eine Funktion auf, die zufällig auf dem Server läuft, und der Pending-Zustand für die Oberfläche fällt nebenbei ab.

flowchart LR
  Form["Formular im Browser<br/>action = payInvoice"]
  Form -->|Absenden| Action["Server Action<br/>&quot;use server&quot;"]
  Action -->|schreibt| DB[("Datenbank")]
  Action -->|revalidatePath| Fresh["Server rendert<br/>betroffene Ansicht neu"]
  Fresh -->|neues Ergebnis| Form

Was das für die Serie bedeutet

Diese neue Grenze verschiebt eine andere, über die ich schon geschrieben habe. In Client-Zustand vs. Server-Cache war der Kernpunkt: Das meiste, was wie Zustand aussieht, ist in Wahrheit geliehener Server-Cache, den man im Browser nur zwischenspeichert. Server Components verschieben diese Grenze noch einmal. Ein großer Teil des Anzeige-Datenladens, für den man bisher eine Cache-Bibliothek im Browser gebraucht hat, wandert jetzt auf den Server – dort liest die Server Component direkt und schickt nur das Ergebnis. Der Bedarf, dieselben Daten im Client nachzuhalten, schrumpft entsprechend.

Das heißt nicht, dass Server-State-Bibliotheken überflüssig werden. Sobald es interaktiv wird – Daten, die sich nach einem Klick ändern, ohne die ganze Seite neu zu laden – bleibt der Cache im Browser sinnvoll. Aber die Aufteilung verschiebt sich: weniger Anzeige im Client, mehr auf dem Server. Für das gestaffelte Ausliefern sorgt dabei Suspense, das die serverseitig gerenderten Teile stückweise zum Browser streamt.

Ehrlich eingeordnet: neu und mit Reibung

Ich will das nicht schönreden. Server Components sind neu, und sie kommen mit Reibung. Man muss die Grenze zwischen Server und Client wirklich verstanden haben, sonst produziert man genau die Fehler, die ich oben beschrieben habe – einen Import über die Grenze, einen Hook auf der falschen Seite, einen Datenzugriff, der plötzlich im Browser landen soll. Das Ökosystem ist noch im Fluss: Die verbreitete Umsetzung ist heute der Next.js App Router, andere Frameworks ziehen nach, und manche Bibliothek muss erst lernen, wo sie in dieser Welt hingehört.

Und nicht jede Anwendung braucht das. Ein hochinteraktives Dashboard, das ohnehin fast nur im Browser lebt, gewinnt wenig, wenn man es durch die Server-Client-Grenze zwängt. Wo sich Server Components dagegen deutlich lohnen: bei datenlastigen, weitgehend statischen Oberflächen, wo viel angezeigt und wenig geklickt wird; wenn das Client-Bundle spürbar drückt und man Anzeige-Code loswerden will, der im Browser nichts zu suchen hat; und dort, wo serverseitig geliefertes HTML für Suchmaschinen und den ersten Eindruck zählt. Das ist eine ehrliche Abwägung, keine Pflicht.

Die eigentliche Entscheidung

Wenn ein Gedanke aus diesem Beitrag hängen bleiben soll, dann dieser: Server Components sind keine schnellere Art zu rendern. Sie sind eine Ortsentscheidung. Bei jedem Teil der Oberfläche fragt man jetzt zuerst: Bleibt das auf dem Server – weil es nur liest und anzeigt – oder gehört es in den Browser, weil ein Mensch damit interagiert? Die Direktiven "use client" und "use server" sind nichts anderes als die sichtbaren Markierungen dieser einen Entscheidung. Wer sie so liest, hat das Wesentliche verstanden, lange bevor er den ersten Hook nachschlägt.

Weiterführende Quellen

Kommentare

Kommentar schreiben