Startseite / Artikel

Laden, Warten, Fehler: Wie passen träge Funktionen, Suspense- und Error-Boundaries zusammen

Verstehen Sie, wie React.lazy, Suspense und Error Boundaries den Code aufteilen, Ersatzlösungen anzeigen und Abstürze abfangen sowie wo jeder Boundary für eine bessere Benutzererfahrung platziert werden sollte.

Verlag: REACTAPP.TOP9 Min. Lesezeit1968 Wörter

Quellen: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide

Ein React-Bildschirm kann sich in drei problematischen Zuständen befinden: Sein Code wurde nicht heruntergeladen, sein Inhalt ist noch nicht bereit, oder es kam bei der Darstellung zu einem Fehler. React.lazy verschiebt die Ladeung des Codes, Suspense zeigt einen Platzhalter an, solange etwas noch aussteht, und ein Error Boundary ersetzt einen fehlerhaften Unterbaum durch eine Ersatzlösung. Sobald Sie verstanden haben, wie diese Elemente miteinander zusammenarbeiten, können Sie die Bündel sinnvoll aufteilen und Grenzen setzen, die den Rest der Seite weiterhin nutzbar halten.

Kodelade verzögern mit React.lazy

Lazy Loading bedeutet, dass der Code einer Komponente nur dann heruntergeladen wird, wenn React ihn zum ersten Mal rendern muss – und nicht als Teil des initialen Bundles. Der Unterschied ist in den Imports zu erkennen: Ein statischer import Dashboard from „./Dashboard“ lädt das Modul sofort, während eine dynamische Import-Anweisung in lazy() es ermöglicht, dass der Bundler einen separaten Chunk sendet. (Im Beispiel ist der zweite Import nur zu Vergleichszwecken gezeigt.)

import { lazy } from "react";

const Dashboard = lazy(() => import("./Dashboard"));

Instead of:

import Dashboard from "./Dashboard";

Was Lazy Loading Ihnen bringt

Stellen Sie sich eine Anwendung mit mehreren unterschiedlichen Bereichen vor:

Application
├── Home
├── Products
├── Dashboard
├── Analytics
├── Admin
└── Payment

Wer auf die Startseite kommt, benötigt diese Bereiche noch nicht:

Analytics
Admin
Payment

Dennoch würde ihr Herunterladen die Größe des initialen Downloads erhöhen. Mit Lazy Loading werden weitere Chunks nach Bedarf geladen:

Initial Load
     ↓
Home + common code
     ↓
User opens Dashboard
     ↓
Download Dashboard chunk
     ↓
Render Dashboard

Wie Lazy Loading im Hintergrund funktioniert

lazy() nimmt eine Funktion entgegen, die ein Promise zurückgibt – meistens eine dynamische import()-Anfrage. Das Zielmodul muss einen Standardexport enthalten, der ein Komponente ist. Die Abfolge sieht so aus (das Diagramm ist konzeptionell und kein Code):

const Dashboard = lazy(() => import("./Dashboard"));

Conceptually:

lazy()
  ↓
dynamic import()
  ↓
JavaScript chunk requested
  ↓
Promise pending
  ↓
Component becomes available
  ↓
React renders component

Daraus ergeben sich zwei praktische Regeln: Deklarieren Sie lazy-Komponenten auf der obersten Ebene des Moduls, niemals innerhalb einer anderen Komponente – sonst wird bei jeder Neuauflösung ein neuer Komponententyp erstellt und dessen Zustand zurückgesetzt. Da der Datenblock asynchron ankommt, muss etwas entscheiden, was der Benutzer in der Zwischenzeit sieht. Das ist die Aufgabe von Suspense.

Einen Platzhalter mit Suspense anzeigen

Suspense zeigt einen Ersatzinhalt an, solange ein Nachkomme pausiert ist, das heißt noch nicht rendern kann. Umhüllen Sie die lazy-Komponente und übergeben Sie den Ersatzelement:

import { lazy, Suspense } from "react";

const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return (
    <Suspense fallback={<p>Loading dashboard...</p>}>
      <Dashboard />
    </Suspense>
  );
}

Während das Datenstück heruntergeladen wird, geht React zum nächstgelegenen Grenzpunkt und zeigt seinen Ersatzinhalt an:

Dashboard
    ↓
Not ready
    ↓
Suspense
    ↓
"Loading dashboard..."

Sobald die Promise abgearbeitet ist, versucht React erneut mit dem Renderen, und der eigentliche Inhalt ersetzt den Ersatzinhalt:

Dashboard loaded
      ↓
React retries rendering
      ↓
Dashboard displayed

Suspense übernimmt nicht das Laden; lazy() (oder eine mit Suspense ausgestattete Datenbibliothek) löst die Verarbeitung aus. Suspense markiert lediglich, wo ein Wartezustand erscheinen kann und wie er aussieht.

Nutzung von lazy und Suspense zusammen

In der Praxis werden die beiden meist kombiniert, wobei in der Regel ein ausführlicherer Ersatzinhalt als nur eine Textzeile verwendet wird:

const Dashboard = lazy(() => import("./Dashboard"));

function App() {
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <Dashboard />
    </Suspense>
  );
}

Der vollständige Lebenszyklus beim ersten Besuch, erneut als Diagramm dargestellt:

User opens Dashboard
        ↓
React tries to render Dashboard
        ↓
Dashboard chunk isn't available
        ↓
Dynamic import starts
        ↓
Suspense fallback shown
        ↓
Dashboard chunk loads
        ↓
React retries rendering
        ↓
Dashboard displayed

Skelette anstelle von Ladeindikatoren

Ein einfacher Text-Ersatzinhalt funktioniert zwar, gibt dem Benutzer aber keine Informationen darüber, was folgen wird:

<Suspense fallback={<p>Loading...</p>}>

Ein Skelettkomponente, die das endgültige Layout nachahmt, ist in der Regel eine bessere Wahl:

<Suspense fallback={<DashboardSkeleton />}>

Es reserviert ungefähr den richtigen Platz, verringert Verschiebungen im Layout und lässt die Wartezeit kürzer erscheinen:

┌─────────────────────────────┐
│ Dashboard                   │
├─────────────────────────────┤
│ ░░░░░░░   ░░░░░░░           │
│ ░░░░░░░   ░░░░░░░           │
│                             │
│ ░░░░░░░░░░░░░░░░░░          │
└─────────────────────────────┘

Verkettung von Suspense-Grenzen

Eine einzige Grenze für die gesamte Anwendung ist selten das, was man möchte. Grenzen können verschachtelt werden, wobei jede suspendierte Komponente von der nächstgelegenen oberen Grenze abgefangen wird:

<Suspense fallback={<PageSkeleton />}>
  <Header />

  <Suspense fallback={<ProductsSkeleton />}>
    <Products />
  </Suspense>

  <Suspense fallback={<RecommendationsSkeleton />}>
    <Recommendations />
  </Suspense>
</Suspense>

Anstatt dass die gesamte Seite in eine einzige Ladeanzeige zusammenfällt,

Entire page → Loading...

kann sich jeder Abschnitt unabhängig anzeigen:

Header                  ✅

Products                Loading...
[Product Skeleton]

Recommendations         Loading...
[Recommendation Skeleton]

Abfangen von Fehlern mit Error Boundaries

Warten ist eine mögliche Situation; ein Fehler ist eine andere. Angenommen, eine Komponente wirft während der Darstellung einen Fehler aus:

function Dashboard() {
  throw new Error("Dashboard crashed!");
}

Ein nicht gefangener Renderungsfehler führt dazu, dass die gesamte React-Root entmontiert wird und eine leere Seite übrig bleibt. Ein Error Boundary ist ein Komponente, die Fehler abfängt, die von ihren Nachkommen ausgelöst werden, und stattdessen eine Ersatz-Oberfläche anzeigt:

Dashboard
    ↓
Rendering Error
    ↓
Error Boundary
    ↓
Fallback UI

Die Verwendung eines Error Boundaries sieht aus wie das Umhüllen einer beliebigen anderen Komponente, wodurch der Benutzer eine Meldung „Etwas ist schiefgelaufen“ statt eines leeren Bildschirms sieht:

<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

Erstellung eines Error Boundary

Error Boundaries sind weiterhin Klassenkomponenten; es gibt in React selbst kein Äquivalent zu Hooks. Zwei Methoden sind wichtig. Die statische Methode getDerivedStateFromError wird während des Renderings ausgeführt und gibt einen Zustand zurück, der auf den Ersatzmodus umschaltet. componentDidCatch wird danach mit dem Fehler sowie info.componentStack ausgeführt, was zum Protokollieren oder Versenden von Berichten dient.

import React from "react";

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      hasError: false,
    };
  }

  static getDerivedStateFromError() {
    return {
      hasError: true,
    };
  }

  componentDidCatch(error, info) {
    console.error(error);
    console.error(info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h2>Something went wrong.</h2>;
    }

    return this.props.children;
  }
}

Er wird genauso verwendet wie im vorherigen Beispiel:

<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

In dieser minimalen Version gibt es keine Reset-Funktion; ein „Try again“-Button muss hasError wieder auf false setzen.

Wie React die Grenze findet

Nehmen wir eine Grenze um ein Profilkomponente herum,

<ErrorBoundary>
  <Profile />
</ErrorBoundary>

wobei die Komponente einen Fehler auslöst:

function Profile() {
  throw new Error("Profile failed");
}

React rendernt den Baum nach unten, bis der Fehler auftritt:

ErrorBoundary
      ↓
Profile
      ↓
💥 Error

Dann sucht es nach oben nach der nächsten Error Boundary:

Profile
   ↑
ErrorBoundary

Diese Grenze wechselt in ihren Fehlerzustand,

{
  hasError: true
}

und rendernt die Ersatzansicht. Eine Grenze kann nur die Fehler ihrer Kinder abfangen, nicht ihre eigenen; ein Fehler innerhalb der Ersatzansicht wird an die nächste obere Grenze weitergeleitet.

Fehler, die eine Grenze abfangen kann

Error Boundaries kümmern sich um Fehler, die während der Renderung von React, in Lifecycle-Methoden sowie in Konstruktoren von Unterkomponenten auftreten. Ein klassisches Beispiel ist das Lesen einer Eigenschaft von undefined während der Renderung:

function Profile() {
  const user = undefined;

  return <h1>{user.name}</h1>;
}

Sie fangen außerdem abgelehnte lazy Imports auf. Wenn beispielsweise ein Chunk nicht heruntergeladen werden kann – nachdem eine Deployment-Aktion die alte Datei entfernt hat – tritt der Fehler am nächsten Error Boundary auf, nicht in Suspense.

Fehler, die von Error Boundaries übersehen werden

Häufig gestellte Frage in Interviews: Error Boundaries erkennen keine Fehler in Event-Handlern, da diese außerhalb der Renderung ausgeführt werden.

function Button() {
  const handleClick = () => {
    throw new Error("Click failed");
  };

  return (
    <button onClick={handleClick}>
      Click
    </button>
  );
}

Behandeln Sie diese Fehler dort, wo sie auftreten, mithilfe herkömmlicher Fehlerbehandlung in den jeweiligen Handlern:

try {
  // operation
} catch (error) {
  // handle error
}

Dasselbe gilt für asynchrone Callbacks wie Timer oder Promise-Chainen, die nach Abschluss der Renderung Fehler auslösen:

setTimeout(() => {
  throw new Error("Async error");
}, 1000);

Falls ein solcher Fehler die Fallback-Methode der Grenze auslöst, fangen Sie ihn ab und speichern Sie ihn im Zustand, um ihn beim nächsten Render wieder auszulösen.

Alle drei Elemente zusammenführen

In einem Produktionsmuster wird Suspense innerhalb einer Error Boundary eingebettet:

<ErrorBoundary>
  <Suspense fallback={<DashboardSkeleton />}>
    <Dashboard />
  </Suspense>
</ErrorBoundary>

Durch die verspätete Definition des Dashboards,

const Dashboard = lazy(() => import("./Dashboard"));

deckt der kombinierte Ablauf alle möglichen Ergebnisse ab. Der erste Ablaufpfad kann als „Fehlender Code?“ interpretiert werden: Falls ja, startet React die Importierung und zeigt das Skelett an; danach ist der Render entweder erfolgreich oder erreicht die Error Boundary.

                    Dashboard
                        │
                        ▼
                  Need the code?
                        │
               ┌────────┴────────┐
               │                 │
              YES                NO
               │                 │
          Render normally    dynamic import()
                                 │
                              Pending
                                 │
                                 ▼
                             Suspense
                                 │
                                 ▼
                            Skeleton UI
                                 │
                              Loaded
                                 │
                                 ▼
                           Render again
                                 │
                         ┌───────┴───────┐
                         │               │
                      Success          Error
                         │               │
                         ▼               ▼
                     Dashboard     Error Boundary
                                         │
                                         ▼
                                      Error UI

Trennung nach Route

Routen sind die natürlichsten Trennpunkte, da jede Seite ein eigenständiges Display darstellt. Deklarieren Sie jede Seite verspätet:

const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Dashboard = lazy(() => import("./pages/Dashboard"));

Dann umhüllen Sie den Router mit einer Grenze, damit bei der Navigation zu einer nicht geladenen Seite ein Lader angezeigt wird:

<Suspense fallback={<PageLoader />}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/products" element={<Products />} />
    <Route path="/dashboard" element={<Dashboard />} />
  </Routes>
</Suspense>

Wissen, was man nicht trennen sollte

Das Verschieben des Ladevorgangs eines kleinen Komponenten ist kontraproduktiv:

const Button = lazy(() => import("./Button"));

Jede solche Komponente verursacht eine Anfrage sowie einen Ladezustand, den ein Button niemals ausgleichen kann. Geeignete Anwendungsgebiete sind große Dashboards, Analyse- und Verwaltungsbereiche, Rich-Text-Editor, Karten, aufwendige Diagramme oder Bibliotheken Dritter, Zahlungsabläufe sowie selten besuchte Seiten. Teilen Sie Code ein, der teuer in der Verarbeitung ist oder selten genutzt wird – nicht alles.

Bewusste Platzierung von Grenzen

Durch die Platzierung des Suspense wird bestimmt, was verschwindet

Eine Grenze hoch im Baum bedeutet, dass jede langsame Komponente einen großen Bereich durch ihren Ersatz ersetzt:

<Suspense fallback={<FullPageLoader />}>
  <EntireApplication />
</Suspense>

Die Platzierung dieser Grenze um den langsamen Teil herum sorgt dafür, dass Header und Footer während des Ladevorgangs weiterhin sichtbar und nutzbar bleiben:

<Header />

<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard />
</Suspense>

<Footer />

Durch die Platzierung der Fehlergrenze wird bestimmt, was erhalten bleibt

Dieselbe Logik gilt auch für Fehler. Eine einzige Grenze auf Ebene der Wurzel verwandelt jeden Fehler in einen Fehlerbildschirm für die gesamte Seite:

<ErrorBoundary>
  <EntireApplication />
</ErrorBoundary>

Das Einbetten unabhängiger Abschnitte hält einen Absturz lokal begrenzt:

<App>
  <Header />

  <ErrorBoundary>
    <Dashboard />
  </ErrorBoundary>

  <ErrorBoundary>
    <Recommendations />
  </ErrorBoundary>

  <Footer />
</App>

Falls Empfehlungen fehlschlagen, funktioniert alles andere weiterhin:

Header              ✅
Dashboard           ✅
Recommendations     ❌
Footer              ✅

Eine Grenze auf Wurzelebene bleibt als letzte Instanz um die feineren Grenzen herum nützlich.

Das mentale Modell in einem Bild

             Lazy Loading
                  │
                  ▼
             Code needed?
                  │
                  ▼
             Suspense
                  │
           ┌──────┴──────┐
           │             │
        Loading         Ready
           │             │
        Fallback         ▼
                       Render
                         │
                   ┌─────┴─────┐
                   │           │
                Success       Error
                   │           │
                   ▼           ▼
                   UI    Error Boundary
                              │
                              ▼
                           Error UI

Wichtige Erkenntnisse

  • React.lazy zusammen mit dynamischem import() teilt den Code in Blöcke auf, die beim ersten Render geladen werden; das Modul benötigt einen Standardexport.
  • Suspense lädt nichts; es legt fest, wo ein Ersatz angezeigt wird, während die Nachkommen pausiert sind.
  • Fehlergrenzen fangen Render-, Lebenszyklus- und Konstruktorfehler ein, einschließlich fehlgeschlagener lazy Imports, aber nicht Fehler von Event-Handlern oder späteren asynchronen Abläufen.
  • Suspense kümmert sich nicht um Fehler, daher sollte es mit einem Error Boundary kombiniert werden.
  • Trennen Sie die Struktur auf Route-Ebene sowie um aufwändige, selten genutzte Funktionen – nicht um kleine Komponenten.
  • Die Platzierung des Boundarys ist eine Entscheidung aus Sicht der Benutzererfahrung: Platzieren Sie beide Arten von Boundarys um sinnvolle, unabhängige Abschnitte.