Blog

React Router Data Router: Die URL als Zustandsort

Der Data Router von React Router macht die URL zum Koordinationspunkt – eine Route lädt ihre Daten per loader vor dem Rendern und schreibt über action, sodass Ladelogik aus dem Komponenten-Body verschwindet.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen

Eine Route ist im Data Router von React Router keine Zuordnung von Pfad zu Komponente mehr. Sie ist eine vollständige Beschreibung dessen, was zu einer Adresse gehört: welcher Ausschnitt der Oberfläche erscheint, woher seine Daten kommen, was bei einem Schreibvorgang passiert, was im Fehlerfall zu sehen ist. Diese Beschreibungen sind Objekte, und weil sie schon vor dem ersten Rendern vorliegen, kann der Router aus ihnen ablesen, was für eine URL zu tun ist – ohne eine einzige Komponente laufen zu lassen.

Wie weit das trägt, zeigt der Vergleich mit dem Muster, das die klassische Variante nahelegt: 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. Im React-Überblick habe ich Routing noch mit BrowserRouter und Routes gezeigt und die moderne Variante bewusst liegengelassen; sie ist der Gegenstand dieses Beitrags. Der Data Router dreht die Reihenfolge um.

Redaktionelle Einordnung (Juli 2026): React Router 8 unterscheidet drei Modi: Declarative, Data und Framework. Dieser Beitrag behandelt den Data Mode mit createBrowserRouter. Der Framework Mode baut darauf auf und ergänzt unter anderem typisierte Route-Module, Code-Splitting sowie mögliche SPA-, SSR- und statische Rendering-Strategien.

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 } from "react-router";
import { RouterProvider } from "react-router/dom";

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, ist der loader abgeschlossen. Ob invoice fachlich existiert, muss der Loader selbst absichern – etwa indem er bei einem leeren Ergebnis eine 404-Response wirft. Das Warten des Routers ersetzt diese Validierung nicht.

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. Damit fällt der Wasserfall weg: Alle betroffenen loader laufen gleichzeitig los, und die Seite ist fertig, sobald der langsamste 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: React Router revalidiert standardmäßig die Loader der aktiven Routen. Die Rechnung wurde bezahlt, die action ist durch, und die Ansicht kann den frischen Stand zeigen, weil die betroffenen Loader noch einmal gelaufen sind. Mit shouldRevalidate lässt sich dieses Verhalten einschränken; externe Caches müssen weiterhin passend invalidiert werden. Der Ablauf ähnelt dem Browserprinzip „Formular abschicken, Seite neu holen“, nur ohne den vollen Dokument-Neuaufbau.

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 im Data Mode von /invoices/42 zu /invoices/43, bleibt die äußere Route ohne eigenen geänderten Parameter standardmäßig stehen; ihr Loader läuft in diesem Beispiel nicht erneut, und nur die innere Route wechselt ihre Daten und ihren Inhalt im <Outlet />. Geänderte Suchparameter oder eine eigene shouldRevalidate-Regel können eine breitere Revalidierung auslösen. 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 viele verstreute if (error)-Zweige durch eine Stelle auf Route-Ebene. Die Route Error Boundary behandelt nicht nur Fehler aus loader und action, sondern auch Renderfehler in ihrem Routenast. Erwartbare fachliche Fehler sollte die Anwendung trotzdem bewusst modellieren, statt jeden negativen Zustand als Exception zu behandeln.

Ein Randthema sei nur angerissen: Manchmal soll die Seite nicht auf die langsamste Datenquelle warten. React Router 8 hat die frühere defer-Hilfsfunktion entfernt. Heute gibt ein Loader nicht kritische Daten als noch nicht aufgelöstes Promise zurück; in der Komponente wartet <Await> innerhalb einer <Suspense>-Grenze darauf. Kritische Daten werden im Loader weiterhin awaitet. So blockiert ein träger Teil nicht die ganze Ansicht, ohne dass eine spezielle defer-Response nötig ist.

Ein Wort zu den drei Modi

Was ich hier gezeigt habe, heißt in der aktuellen Dokumentation Data Mode: createBrowserRouter in einer bestehenden Anwendung. Der Declarative Mode mit BrowserRouter bietet das klassische Routing ohne die Data APIs. Der Framework Mode setzt auf den Data Mode auf, organisiert Routen als Module und ergänzt Build- und Server-Funktionen. 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 –, bleiben im Data und Framework Mode dieselben.

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 liegt: 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

Wie fandest du diesen Beitrag?

Kommentare