React Router Data Router: Die URL als Zustandsort
Der Data Router von React Router macht die URL zur Datenquelle – eine Route lädt ihre Daten per loader, bevor die Komponente rendert, und schreibt über action, sodass Ladelogik aus dem Komponenten-Body verschwindet.
React Router Data Router: Die URL als Zustandsort
Im React-Überblick habe ich Routing mit BrowserRouter und Routes gezeigt und einen Satz danebengestellt, den ich für einen der wichtigsten der ganzen Frontend-Entwicklung halte: Die URL ist ein Zustandsort. Zugleich habe ich die moderne Variante von React Router dort bewusst liegengelassen – sie sei „ein Thema für sich“. Das ist dieser Beitrag. Denn seit React Router 7 ist die interessante Frage nicht mehr, wie man von einer Seite zur nächsten kommt, sondern wo die Daten einer Seite herkommen. Und die Antwort verschiebt sich mit dem Data Router an eine Stelle, an der viele sie zuerst nicht vermuten: an die Route selbst.
In Schulungen sehe ich fast immer dasselbe Muster. Eine Route rendert eine Komponente, die Komponente hat einen useEffect, der Effekt lädt die Daten, und daneben liegen drei useState für Ladezustand, Fehler und Ergebnis. Bei einer Seite geht das. Bei zwanzig verschachtelten Seiten entstehen Ladewasserfälle: Die äußere Komponente rendert, ihr Effekt lädt, erst danach rendert die innere, ihr Effekt lädt – nacheinander, obwohl beide von Anfang an bekannt waren. Der Data Router dreht das um.
Die Kernidee: Die Route lädt, bevor sie rendert
Der entscheidende Unterschied lässt sich in einem Satz sagen. Bei der alten Variante rendert die Komponente zuerst und lädt dann ihre Daten. Beim Data Router lädt die Route zuerst ihre Daten und rendert dann die Komponente – mit den Daten bereits in der Hand. Jede Route bekommt dafür eine Funktion, den loader. Er läuft beim Navigieren, noch bevor irgendetwas auf den Bildschirm kommt. Erst wenn er fertig ist, rendert React die Komponente, und die greift die Daten fertig geladen ab, statt sie selbst zu besorgen.
Damit verschwindet die Ladelogik aus dem Komponenten-Body. Kein useEffect mehr, der beim ersten Rendern nachlädt, keine Handvoll useState für einen Zustand, den es genau genommen nie geben sollte – denn wenn die Komponente rendert, sind die Daten längst da. Das schreibende Gegenstück heißt action. Es nimmt Formular-Übermittlungen entgegen, schreibt zum Server, und danach lädt React Router die betroffenen loader von selbst neu, damit die Ansicht wieder stimmt.
createBrowserRouter und die Route-Objekte
Man beschreibt die Anwendung nicht mehr als verschachtelte JSX-Elemente, sondern als Objekte. createBrowserRouter nimmt ein Array von Route-Definitionen entgegen, und jede Definition trägt ihre Bausteine an einer Stelle: den Pfad, die Komponente, den loader, die action, das Fehler-Element.
import { createBrowserRouter, RouterProvider } from "react-router";
const router = createBrowserRouter([
{
path: "/invoices",
element: <InvoiceList />,
loader: invoiceListLoader,
errorElement: <RouteError />,
children: [
{
path: ":invoiceId",
element: <InvoiceDetail />,
loader: invoiceDetailLoader,
action: invoiceDetailAction,
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
Statt path und element über die halbe Anwendung verstreut zu suchen, steht alles, was eine Route ausmacht, beisammen: was sie anzeigt, woher ihre Daten kommen, wie sie auf Schreibvorgänge reagiert, was bei einem Fehler passiert. Diese Bündelung ist kein kosmetischer Vorteil. Sie ist die Voraussetzung dafür, dass React Router die Daten schon kennt, bevor gerendert wird – denn der Router kann aus dem Objektbaum ablesen, welche loader für eine URL zuständig sind, ohne die Komponenten erst zu rendern.
Der loader: Daten holen, bevor gerendert wird
Ein loader ist eine gewöhnliche Funktion. Sie bekommt unter anderem die Parameter aus dem Pfad, holt die Daten und gibt sie zurück. In der Komponente greift man sie mit useLoaderData() ab.
import { useLoaderData } from "react-router";
async function invoiceDetailLoader({ params }) {
const response = await fetch(`/api/invoices/${params.invoiceId}`);
if (!response.ok) {
throw new Response("Not found", { status: 404 });
}
return response.json();
}
function InvoiceDetail() {
const invoice = useLoaderData();
return <h1>Invoice {invoice.number}</h1>;
}
Man beachte, was hier nicht steht. Kein useState für die Rechnung, kein useEffect fürs Laden, keine Prüfung auf null, solange noch geladen wird. Wenn InvoiceDetail rendert, existiert invoice garantiert – React Router hat gewartet, bis der loader fertig war, und rendert die Komponente erst danach.
Der eigentliche Gewinn zeigt sich bei verschachtelten Routen. Öffnet jemand /invoices/42, sind zwei Routen betroffen: die Liste und das Detail. Bei der useEffect-Variante liefe das nacheinander – erst rendert die Liste und lädt, dann rendert das Detail und lädt. React Router kennt beide loader von vornherein und startet sie parallel. Kein Wasserfall, sondern ein gemeinsamer Startschuss, und die Seite ist fertig, sobald der langsamste loader durch ist.
flowchart TB Nav["Navigation zu /invoices/42"] Nav --> Match["Router ermittelt betroffene Routen"] Match --> L1["loader Liste"] Match --> L2["loader Detail"] L1 --> Ready["alle loader fertig"] L2 --> Ready Ready --> Render["Komponenten rendern<br/>mit useLoaderData"]
Hier lohnt der Bezug zum Server-Cache. Im Beitrag zu Client-Zustand und Server-Cache habe ich gezeigt, dass Serverdaten kein Zustand sind, den man besitzt, sondern eine Kopie, die man verwaltet – mit Schlüssel, Alter und Invalidierung, wofür sich eine Bibliothek wie TanStack Query anbietet. Der loader liegt eine Ebene darüber. Er sagt, wann geladen wird, nämlich beim Navigieren, gebunden an die Route statt an die Komponente. Ob dahinter ein nackter fetch steht oder ein Cache, ist davon unberührt. Beides ergänzt sich sauber: Der loader ruft den queryClient auf, und die Bibliothek entscheidet, ob sie eine frische Kopie hält oder wirklich neu lädt. Der Router bestimmt den Zeitpunkt, der Cache die Gültigkeit – sie schließen sich nicht aus, sie greifen ineinander.
Die action: Schreiben über <Form>
Für schreibende Vorgänge hält React Router eine eigene Komponente bereit. <Form method="post"> sieht aus wie ein gewöhnliches HTML-Formular, wird aber vom Router abgefangen: Statt die Seite neu zu laden, ruft er die action der passenden Route auf und übergibt ihr die Formulardaten.
import { Form, redirect } from "react-router";
async function invoiceDetailAction({ request, params }) {
const formData = await request.formData();
await fetch(`/api/invoices/${params.invoiceId}/pay`, {
method: "POST",
body: formData,
});
return redirect(`/invoices/${params.invoiceId}`);
}
function InvoiceDetail() {
const invoice = useLoaderData();
return (
<Form method="post">
<button type="submit">Mark as paid</button>
</Form>
);
}
Das Entscheidende passiert nach der action, ohne dass man etwas dafür tut: React Router lädt die loader der aktiven Routen automatisch neu – man nennt das Revalidierung. Die Rechnung wurde bezahlt, die action ist durch, und die Ansicht zeigt den frischen Stand, weil der loader von selbst noch einmal gelaufen ist. Man muss nicht von Hand nachladen, keinen Zustand umsetzen, keine Kopie invalidieren. Der Ablauf ist derselbe wie im Browser seit jeher – Formular abschicken, Seite neu holen –, nur ohne den vollen Neuaufbau und ohne den Flackereffekt.
flowchart LR F["Form method=post"] --> A["action schreibt<br/>zum Server"] A --> R["loader werden<br/>revalidiert"] R --> U["Ansicht zeigt<br/>frischen Stand"]
Wer das mit dem useEffect-Dashboard aus dem Server-Cache-Beitrag vergleicht, erkennt dieselbe Krankheit und eine andere Medizin. Dort liefen die Zahl oben und die Liste daneben auseinander, weil niemand nach dem Bezahlen dafür sorgte, dass beide neu luden. Hier laufen sie nach der action gemeinsam neu, weil die Revalidierung alle loader der aktiven Route-Ebenen betrifft und nicht eine einzelne Komponente.
Verschachtelte Routen und <Outlet />
Die Objekte aus dem ersten Beispiel hatten children, und das ist mehr als eine Verwaltungsfrage. Verschachtelte Routen bilden die UI-Hierarchie ab: Eine äußere Layout-Route rendert den Rahmen – Navigation, Kopfzeile, Randspalte –, und dort, wo die innere Route erscheinen soll, setzt sie einen <Outlet />.
import { Outlet, NavLink } from "react-router";
function InvoiceList() {
const invoices = useLoaderData();
return (
<div className="layout">
<nav>
{invoices.map((invoice) => (
<NavLink key={invoice.id} to={`/invoices/${invoice.id}`}>
{invoice.number}
</NavLink>
))}
</nav>
<main>
<Outlet />
</main>
</div>
);
}
Navigiert man von /invoices/42 zu /invoices/43, bleibt die äußere Route stehen – ihr loader läuft nicht erneut, das Layout wird nicht neu aufgebaut –, und nur die innere Route wechselt ihre Daten und ihren Inhalt im <Outlet />. Die URL liest sich damit wie ein Pfad durch den Baum der Oberfläche: Jedes Segment steht für eine Ebene, jede Ebene für eine Route, jede Route für ein Stück UI mit eigenen Daten. Eine Layout-Route ganz ohne path kann übrigens allein dem gemeinsamen Rahmen dienen, während ihre Kinder die eigentlichen Adressen tragen.
Die URL als Zustand: Suchparameter
Bis hierher ging es um den Pfad. Der zweite Teil der URL ist mindestens so nützlich: die Suchparameter hinter dem ?. Im React-Überblick habe ich dafür plädiert, dass Auswahl und Filter in die URL gehören – und useSearchParams ist das Werkzeug dazu. Es fühlt sich an wie useState, schreibt seinen Wert aber in die Adresszeile.
import { useSearchParams } from "react-router";
function InvoiceFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const status = searchParams.get("status") ?? "all";
return (
<select
value={status}
onChange={(event) =>
setSearchParams({ status: event.target.value })
}
>
<option value="all">All</option>
<option value="open">Open</option>
<option value="paid">Paid</option>
</select>
);
}
Der gewählte Filter steht jetzt in der URL – /invoices?status=open –, und das ändert seinen Charakter grundlegend. Dieser Zustand ist teilbar: Man schickt den Link, und der Empfänger sieht dieselbe gefilterte Liste. Er ist mit einem Lesezeichen speicherbar. Er überlebt einen Reload, weil er nicht im Komponentenspeicher liegt, sondern in der Adresse. Und er lässt sich mit dem loader verbinden: Da der loader Zugriff auf die request-URL hat, kann er den status-Parameter auslesen und gleich die passend gefilterte Liste vom Server holen. Der Filter steht in der URL, der loader liest ihn, der Server liefert – aus einem Stück UI-Zustand wird ein navigierbarer Ort.
Fehler und langsame Teildaten
Weil der loader vor dem Rendern läuft, braucht das Fehlschlagen einen eigenen Platz. Wirft ein loader oder eine action – etwa die throw new Response(..., { status: 404 }) von oben –, sucht React Router die nächste Route mit errorElement und rendert diese statt der Komponente. Innerhalb des Fehler-Elements kommt man mit useRouteError() an das, was geworfen wurde.
import { useRouteError } from "react-router";
function RouteError() {
const error = useRouteError();
return <p role="alert">Something went wrong: {error.status}</p>;
}
Das ersetzt die verstreuten if (error)-Zweige in jeder Komponente durch eine Grenze auf Route-Ebene – ähnlich wie eine Error Boundary, nur für Ladefehler statt für Render-Fehler, und schon eingebaut.
Ein Randthema sei nur angerissen: Manchmal soll die Seite nicht auf die langsamste Datenquelle warten. Mit defer gibt ein loader schnelle Daten sofort zurück und langsame als noch nicht aufgelöstes Promise; in der Komponente zeigt <Await> innerhalb einer <Suspense>-Grenze so lange einen Platzhalter und streamt das Ergebnis nach, sobald es da ist. So blockiert ein träger Teil nicht die ganze Ansicht. Für den Alltag reicht meist der einfache loader; defer ist die Reserve für den einen langsamen Aufruf neben vielen schnellen.
Ein Wort zum Framework-Modus
Seit Version 7 sind die beiden Gesichter von React Router zusammengewachsen. Was ich hier gezeigt habe, ist der Library-Modus: createBrowserRouter in einer bestehenden Single-Page-Anwendung. Daneben gibt es den Framework-Modus, der Routen aus Dateien ableitet und in Richtung Server-Rendering und React Server Components geht. Das ist ein eigenes, größeres Thema, und ich lasse es hier bewusst stehen. Die Konzepte, die zählen – loader, action, Revalidierung, die URL als Zustandsort –, sind in beiden Modi dieselben. Wer sie im Library-Modus verstanden hat, findet sich im Framework-Modus sofort zurecht.
Wohin die Daten gehören
Der rote Faden dieses Beitrags ist eine einzige Verschiebung. Beim klassischen Ansatz hängen Laden und Schreiben an der Komponente: Sie rendert, ihr Effekt holt, ihre Handler schreiben, und bei Verschachtelung reihen sich die Ladevorgänge zu einem Wasserfall. Der Data Router verankert beides an der Route. Der loader holt die Daten, bevor gerendert wird, und benachbarte Ebenen parallel; die action schreibt und lässt die loader danach von selbst revalidieren. Was in der Komponente bleibt, ist das Anzeigen – nicht das Besorgen.
Damit wird aus der URL endgültig das, was sie im Überblick nur behauptet hat zu sein. Sie ist nicht mehr das Adressfeld, das anzeigt, wo man ist. Sie ist der Ort, an dem der navigierbare Zustand wirklich lebt: Der Pfad bestimmt, welche Routen und damit welche Daten geladen werden, die Suchparameter tragen Filter und Auswahl, und beides zusammen ist teilbar, speicherbar und übersteht jeden Reload. Wer einmal so baut, gibt die Ladelogik nicht ungern aus dem Komponenten-Body her.
Weiterführende Quellen
- Repository introduction-react (Routing-Kapitel, Ordner recipes zum Data Fetching): https://github.com/mikebild/introduction-react
- React Router, offizielle Dokumentation: https://reactrouter.com/
- React Router, „Data Loading“: https://reactrouter.com/start/data/data-loading
- React Router, „Actions“: https://reactrouter.com/start/data/actions
Kommentare
Kommentar schreiben