Deutsch

Meistern Sie React Suspense für den Datenabruf. Lernen Sie, Ladezustände deklarativ zu verwalten, die UX mit Transitions zu verbessern und Fehler mit Error Boundaries zu behandeln.

React Suspense Boundaries: Ein tiefer Einblick in die deklarative Verwaltung von Ladezuständen

In der Welt der modernen Webentwicklung ist die Schaffung eines nahtlosen und reaktionsschnellen Benutzererlebnisses von größter Bedeutung. Eine der hartnäckigsten Herausforderungen für Entwickler ist die Verwaltung von Ladezuständen. Vom Abrufen von Daten für ein Benutzerprofil bis zum Laden eines neuen Abschnitts einer Anwendung sind die Momente des Wartens entscheidend. In der Vergangenheit war dies mit einem Wirrwarr aus booleschen Flags wie isLoading, isFetching und hasError verbunden, die über unsere Komponenten verstreut waren. Dieser imperative Ansatz überlädt unseren Code, verkompliziert die Logik und ist eine häufige Fehlerquelle, wie z. B. für Race Conditions.

Hier kommt React Suspense ins Spiel. Ursprünglich für Code-Splitting mit React.lazy() eingeführt, wurden seine Fähigkeiten mit React 18 drastisch erweitert, um zu einem leistungsstarken, erstklassigen Mechanismus für die Handhabung asynchroner Operationen, insbesondere des Datenabrufs, zu werden. Suspense ermöglicht es uns, Ladezustände auf deklarative Weise zu verwalten und verändert grundlegend, wie wir unsere Komponenten schreiben und über sie nachdenken. Anstatt zu fragen „Lade ich gerade?“, können unsere Komponenten einfach sagen: „Ich benötige diese Daten zum Rendern. Während ich warte, zeige bitte diese Fallback-UI an.“

Dieser umfassende Leitfaden nimmt Sie mit auf eine Reise von den traditionellen Methoden der Zustandsverwaltung zum deklarativen Paradigma von React Suspense. Wir werden untersuchen, was Suspense Boundaries sind, wie sie sowohl für Code-Splitting als auch für den Datenabruf funktionieren und wie man komplexe Lade-UIs orchestriert, die Ihre Benutzer erfreuen, anstatt sie zu frustrieren.

Der alte Weg: Die Mühsal manueller Ladezustände

Bevor wir die Eleganz von Suspense voll und ganz würdigen können, ist es wichtig, das Problem zu verstehen, das es löst. Schauen wir uns eine typische Komponente an, die Daten mit den useEffect- und useState-Hooks abruft.

Stellen Sie sich eine Komponente vor, die Benutzerdaten abrufen und anzeigen muss:


import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Zustand für neue userId zurücksetzen
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('Netzwerkantwort war nicht in Ordnung');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Erneut abrufen, wenn sich userId ändert

  if (isLoading) {
    return <p>Profil wird geladen...</p>;
  }

  if (error) {
    return <p>Fehler: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>E-Mail: {user.email}</p>
    </div>
  );
}

Dieses Muster ist funktional, hat aber mehrere Nachteile:

Bühne frei für React Suspense: Ein Paradigmenwechsel

Suspense stellt dieses Modell auf den Kopf. Anstatt dass die Komponente den Ladezustand intern verwaltet, kommuniziert sie ihre Abhängigkeit von einer asynchronen Operation direkt an React. Wenn die benötigten Daten noch nicht verfügbar sind, „unterbricht“ die Komponente das Rendern.

Wenn eine Komponente unterbricht, geht React den Komponentenbaum nach oben, um die nächste Suspense Boundary zu finden. Eine Suspense Boundary ist eine Komponente, die Sie in Ihrem Baum mit <Suspense> definieren. Diese Boundary rendert dann eine Fallback-UI (wie einen Spinner oder einen Skeleton Loader), bis alle Komponenten innerhalb dieser Boundary ihre Datenabhängigkeiten aufgelöst haben.

Die Kernidee besteht darin, die Datenabhängigkeit bei der Komponente zu platzieren, die sie benötigt, während die Lade-UI auf einer höheren Ebene im Komponentenbaum zentralisiert wird. Dies bereinigt die Komponentenlogik und gibt Ihnen eine leistungsstarke Kontrolle über das Ladeerlebnis des Benutzers.

Wie „unterbricht“ eine Komponente das Rendern?

Die Magie hinter Suspense liegt in einem Muster, das auf den ersten Blick ungewöhnlich erscheinen mag: das Werfen eines Promise. Eine Suspense-fähige Datenquelle funktioniert so:

  1. Wenn eine Komponente nach Daten fragt, prüft die Datenquelle, ob sie die Daten zwischengespeichert hat.
  2. Wenn die Daten verfügbar sind, gibt sie diese synchron zurück.
  3. Wenn die Daten nicht verfügbar sind (d. h. sie werden gerade abgerufen), wirft die Datenquelle das Promise, das die laufende Abfrageanforderung darstellt.

React fängt dieses geworfene Promise ab. Es stürzt Ihre App nicht ab. Stattdessen interpretiert es dies als Signal: „Diese Komponente ist noch nicht bereit zum Rendern. Pausiere sie und suche nach einer Suspense Boundary darüber, um ein Fallback anzuzeigen.“ Sobald das Promise aufgelöst ist, versucht React erneut, die Komponente zu rendern, die nun ihre Daten erhält und erfolgreich rendert.

Die <Suspense>-Boundary: Ihr Deklarator für die Lade-UI

Die <Suspense>-Komponente ist das Herzstück dieses Musters. Sie ist unglaublich einfach zu verwenden und benötigt eine einzige, erforderliche Prop: fallback.


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>Meine Anwendung</h1>
      <Suspense fallback={<p>Inhalt wird geladen...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

In diesem Beispiel sieht der Benutzer die Meldung „Inhalt wird geladen...“, wenn SomeComponentThatFetchesData unterbricht, bis die Daten bereit sind. Das Fallback kann jeder gültige React-Knoten sein, von einem einfachen String bis zu einer komplexen Skeleton-Komponente.

Klassischer Anwendungsfall: Code Splitting mit React.lazy()

Die etablierteste Verwendung von Suspense ist das Code Splitting. Es ermöglicht Ihnen, das Laden des JavaScript für eine Komponente aufzuschieben, bis sie tatsächlich benötigt wird.


import React, { Suspense, lazy } from 'react';

// Der Code dieser Komponente wird nicht im initialen Bundle enthalten sein.
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>Einige Inhalte, die sofort geladen werden</h2>
      <Suspense fallback={<div>Komponente wird geladen...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

Hier wird React das JavaScript für HeavyComponent erst dann abrufen, wenn es zum ersten Mal versucht, es zu rendern. Während es abgerufen und geparst wird, wird das Suspense-Fallback angezeigt. Dies ist eine leistungsstarke Technik zur Verbesserung der anfänglichen Ladezeiten der Seite.

Die moderne Grenze: Datenabruf mit Suspense

Obwohl React den Suspense-Mechanismus bereitstellt, bietet es keinen spezifischen Client für den Datenabruf. Um Suspense für den Datenabruf zu verwenden, benötigen Sie eine Datenquelle, die damit integriert ist (d. h. eine, die ein Promise wirft, wenn Daten ausstehen).

Frameworks wie Relay und Next.js haben eine eingebaute, erstklassige Unterstützung für Suspense. Beliebte Datenabruf-Bibliotheken wie TanStack Query (ehemals React Query) und SWR bieten ebenfalls experimentelle oder volle Unterstützung dafür.

Um das Konzept zu verstehen, erstellen wir einen sehr einfachen, konzeptionellen Wrapper um die fetch-API, um sie Suspense-kompatibel zu machen. Hinweis: Dies ist ein vereinfachtes Beispiel zu Lehrzwecken und ist nicht produktionsreif. Es fehlt eine ordnungsgemäße Zwischenspeicherung und die Feinheiten der Fehlerbehandlung.


// data-fetcher.js
// Ein einfacher Cache zum Speichern von Ergebnissen
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // Das ist die Magie!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Abruf fehlgeschlagen mit Status ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

Dieser Wrapper pflegt einen einfachen Status für jede URL. Wenn fetchData aufgerufen wird, prüft es den Status. Wenn er ausstehend ist, wirft es das Promise. Wenn er erfolgreich ist, gibt es die Daten zurück. Schreiben wir nun unsere UserProfile-Komponente damit neu.


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// Die Komponente, die die Daten tatsächlich verwendet
function ProfileDetails({ userId }) {
  // Versuche, die Daten zu lesen. Wenn sie nicht bereit sind, wird dies eine Unterbrechung auslösen.
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>E-Mail: {user.email}</p>
    </div>
  );
}

// Die Elternkomponente, die die Ladezustands-UI definiert
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>Profil wird geladen...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

Sehen Sie sich den Unterschied an! Die ProfileDetails-Komponente ist sauber und konzentriert sich ausschließlich auf das Rendern der Daten. Sie hat keine isLoading- oder error-Zustände. Sie fordert einfach die Daten an, die sie benötigt. Die Verantwortung für die Anzeige eines Ladeindikators wurde auf die übergeordnete Komponente, UserProfile, verlagert, die deklarativ angibt, was während des Wartens angezeigt werden soll.

Orchestrierung komplexer Ladezustände

Die wahre Stärke von Suspense wird deutlich, wenn Sie komplexe UIs mit mehreren asynchronen Abhängigkeiten erstellen.

Verschachtelte Suspense Boundaries für eine gestaffelte UI

Sie können Suspense Boundaries verschachteln, um ein verfeinertes Ladeerlebnis zu schaffen. Stellen Sie sich eine Dashboard-Seite mit einer Seitenleiste, einem Hauptinhaltsbereich und einer Liste der letzten Aktivitäten vor. Jede dieser Komponenten könnte ihren eigenen Datenabruf erfordern.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="layout">
        <Suspense fallback={<p>Navigation wird geladen...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

Mit dieser Struktur:

Dies ermöglicht es Ihnen, dem Benutzer so schnell wie möglich nützliche Inhalte anzuzeigen und die wahrgenommene Leistung drastisch zu verbessern.

Vermeidung von UI-"Popcorning"

Manchmal kann der gestaffelte Ansatz zu einem störenden Effekt führen, bei dem mehrere Spinner in schneller Folge erscheinen und verschwinden, ein Effekt, der oft als „Popcorning“ bezeichnet wird. Um dies zu lösen, können Sie die Suspense Boundary weiter oben im Baum platzieren.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

In dieser Version wird ein einzelnes DashboardSkeleton angezeigt, bis alle untergeordneten Komponenten (Sidebar, MainContent, ActivityFeed) ihre Daten bereit haben. Das gesamte Dashboard erscheint dann auf einmal. Die Wahl zwischen verschachtelten Boundaries und einer einzigen höherstufigen Boundary ist eine UX-Design-Entscheidung, deren Umsetzung Suspense trivial macht.

Fehlerbehandlung mit Error Boundaries

Suspense behandelt den ausstehenden Zustand eines Promise, aber was ist mit dem abgelehnten Zustand? Wenn das von einer Komponente geworfene Promise ablehnt (z. B. bei einem Netzwerkfehler), wird es wie jeder andere Rendering-Fehler in React behandelt.

Die Lösung besteht darin, Error Boundaries zu verwenden. Eine Error Boundary ist eine Klassenkomponente, die eine spezielle Lebenszyklusmethode, componentDidCatch() oder eine statische Methode getDerivedStateFromError(), definiert. Sie fängt JavaScript-Fehler überall in ihrem untergeordneten Komponentenbaum ab, protokolliert diese Fehler und zeigt eine Fallback-UI an.

Hier ist eine einfache Error Boundary-Komponente:


import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // Zustand aktualisieren, damit der nächste Render die Fallback-UI anzeigt.
    return { hasError: true, error: error };
  }

  componentDidCatch(error, errorInfo) {
    // Sie können den Fehler auch an einen Fehlerberichterstattungsdienst protokollieren
    console.error("Ein Fehler wurde abgefangen:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Sie können jede beliebige Fallback-UI rendern
      return <h1>Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.</h1>;
    }

    return this.props.children; 
  }
}

Sie können dann Error Boundaries mit Suspense kombinieren, um ein robustes System zu erstellen, das alle drei Zustände behandelt: ausstehend, erfolgreich und fehlerhaft.


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>Benutzerinformationen</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>Wird geladen...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Mit diesem Muster wird das Profil angezeigt, wenn der Datenabruf in UserProfile erfolgreich ist. Wenn er aussteht, wird das Suspense-Fallback angezeigt. Wenn er fehlschlägt, wird das Fallback der Error Boundary angezeigt. Die Logik ist deklarativ, komponierbar und leicht nachvollziehbar.

Transitions: Der Schlüssel zu nicht blockierenden UI-Updates

Es gibt ein letztes Puzzleteil. Betrachten Sie eine Benutzerinteraktion, die einen neuen Datenabruf auslöst, wie das Klicken auf einen „Weiter“-Button, um ein anderes Benutzerprofil anzuzeigen. Mit dem obigen Setup wird die UserProfile-Komponente in dem Moment, in dem der Button geklickt wird und sich die userId-Prop ändert, erneut unterbrechen. Das bedeutet, dass das aktuell sichtbare Profil verschwindet und durch das Lade-Fallback ersetzt wird. Dies kann sich abrupt und störend anfühlen.

Hier kommen Transitions ins Spiel. Transitions sind eine neue Funktion in React 18, mit der Sie bestimmte Zustandsaktualisierungen als nicht dringend markieren können. Wenn eine Zustandsaktualisierung in eine Transition gehüllt wird, zeigt React weiterhin die alte UI (den veralteten Inhalt) an, während es den neuen Inhalt im Hintergrund vorbereitet. Es wird das UI-Update erst dann übernehmen, wenn der neue Inhalt zur Anzeige bereit ist.

Die primäre API dafür ist der useTransition-Hook.


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        Nächster Benutzer
      </button>

      {isPending && <span> Neues Profil wird geladen...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>Initiales Profil wird geladen...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Folgendes passiert jetzt:

  1. Das initiale Profil für userId: 1 wird geladen und zeigt das Suspense-Fallback an.
  2. Der Benutzer klickt auf „Nächster Benutzer“.
  3. Der setUserId-Aufruf ist in startTransition gehüllt.
  4. React beginnt, die UserProfile-Komponente mit der neuen userId von 2 im Speicher zu rendern. Dies führt dazu, dass sie unterbricht.
  5. Entscheidend ist, anstatt das Suspense-Fallback anzuzeigen, behält React die alte UI (das Profil für Benutzer 1) auf dem Bildschirm.
  6. Der von useTransition zurückgegebene boolesche Wert isPending wird zu true, was es uns ermöglicht, einen dezenten, inline Ladeindikator anzuzeigen, ohne den alten Inhalt zu entfernen.
  7. Sobald die Daten für Benutzer 2 abgerufen sind und UserProfile erfolgreich rendern kann, übernimmt React das Update, und das neue Profil erscheint nahtlos.

Transitions bieten die letzte Steuerungsebene und ermöglichen es Ihnen, anspruchsvolle und benutzerfreundliche Ladeerlebnisse zu schaffen, die sich nie störend anfühlen.

Best Practices und allgemeine Überlegungen

Fazit

React Suspense repräsentiert mehr als nur eine neue Funktion; es ist eine grundlegende Weiterentwicklung in der Art und Weise, wie wir Asynchronität in React-Anwendungen angehen. Indem wir uns von manuellen, imperativen Lade-Flags verabschieden und ein deklaratives Modell annehmen, können wir Komponenten schreiben, die sauberer, widerstandsfähiger und einfacher zu komponieren sind.

Durch die Kombination von <Suspense> für ausstehende Zustände, Error Boundaries für Fehlerzustände und useTransition für nahtlose Updates steht Ihnen ein vollständiges und leistungsstarkes Toolkit zur Verfügung. Sie können alles von einfachen Lade-Spinnern bis hin zu komplexen, gestaffelten Dashboard-Enthüllungen mit minimalem, vorhersagbarem Code orchestrieren. Wenn Sie anfangen, Suspense in Ihre Projekte zu integrieren, werden Sie feststellen, dass es nicht nur die Leistung und das Benutzererlebnis Ihrer Anwendung verbessert, sondern auch Ihre Zustandsverwaltungslogik drastisch vereinfacht, sodass Sie sich auf das konzentrieren können, was wirklich zählt: großartige Funktionen zu entwickeln.