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:
- Boilerplate-Code: Wir benötigen mindestens drei Zustandsvariablen (
data
,isLoading
,error
) für jede einzelne asynchrone Operation. Dies skaliert in einer komplexen Anwendung schlecht. - Verstreute Logik: Die Rendering-Logik ist mit bedingten Prüfungen (
if (isLoading)
,if (error)
) fragmentiert. Die primäre „Happy Path“-Renderlogik wird ganz nach unten geschoben, was die Komponente schwerer lesbar macht. - Race Conditions: Der
useEffect
-Hook erfordert eine sorgfältige Verwaltung der Abhängigkeiten. Ohne ordnungsgemäße Bereinigung könnte eine schnelle Antwort von einer langsamen Antwort überschrieben werden, wenn sich dieuserId
-Prop schnell ändert. Obwohl unser Beispiel einfach ist, können komplexe Szenarien leicht subtile Fehler einführen. - Wasserfall-Abrufe: Wenn eine untergeordnete Komponente ebenfalls Daten abrufen muss, kann sie nicht einmal mit dem Rendern (und damit dem Abrufen) beginnen, bis die übergeordnete Komponente das Laden abgeschlossen hat. Dies führt zu ineffizienten Datenlade-Wasserfällen.
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:
- Wenn eine Komponente nach Daten fragt, prüft die Datenquelle, ob sie die Daten zwischengespeichert hat.
- Wenn die Daten verfügbar sind, gibt sie diese synchron zurück.
- 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:
- Die
Sidebar
kann erscheinen, sobald ihre Daten bereit sind, auch wenn der Hauptinhalt noch lädt. - Der
MainContent
und derActivityFeed
können unabhängig voneinander laden. Der Benutzer sieht einen detaillierten Skeleton Loader für jeden Abschnitt, was einen besseren Kontext bietet als ein einziger, seitenweiter Spinner.
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:
- Das initiale Profil für
userId: 1
wird geladen und zeigt das Suspense-Fallback an. - Der Benutzer klickt auf „Nächster Benutzer“.
- Der
setUserId
-Aufruf ist instartTransition
gehüllt. - React beginnt, die
UserProfile
-Komponente mit der neuenuserId
von 2 im Speicher zu rendern. Dies führt dazu, dass sie unterbricht. - Entscheidend ist, anstatt das Suspense-Fallback anzuzeigen, behält React die alte UI (das Profil für Benutzer 1) auf dem Bildschirm.
- Der von
useTransition
zurückgegebene boolesche WertisPending
wird zutrue
, was es uns ermöglicht, einen dezenten, inline Ladeindikator anzuzeigen, ohne den alten Inhalt zu entfernen. - 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
- Platzieren Sie Boundaries strategisch: Hüllen Sie nicht jede winzige Komponente in eine Suspense Boundary. Platzieren Sie sie an logischen Punkten in Ihrer Anwendung, an denen ein Ladezustand für den Benutzer sinnvoll ist, wie eine Seite, ein großes Panel oder ein wichtiges Widget.
- Entwerfen Sie aussagekräftige Fallbacks: Generische Spinner sind einfach, aber Skeleton Loaders, die die Form des zu ladenden Inhalts nachahmen, bieten ein viel besseres Benutzererlebnis. Sie reduzieren Layout Shift und helfen dem Benutzer zu antizipieren, welche Inhalte erscheinen werden.
- Berücksichtigen Sie die Barrierefreiheit: Stellen Sie beim Anzeigen von Ladezuständen sicher, dass diese zugänglich sind. Verwenden Sie ARIA-Attribute wie
aria-busy="true"
auf dem Inhaltscontainer, um Screenreader-Benutzer darüber zu informieren, dass der Inhalt aktualisiert wird. - Nutzen Sie Server Components: Suspense ist eine grundlegende Technologie für React Server Components (RSC). Bei der Verwendung von Frameworks wie Next.js ermöglicht Suspense das Streamen von HTML vom Server, sobald Daten verfügbar werden, was zu unglaublich schnellen anfänglichen Seitenladezeiten für ein globales Publikum führt.
- Nutzen Sie das Ökosystem: Obwohl das Verständnis der zugrunde liegenden Prinzipien wichtig ist, sollten Sie sich für Produktionsanwendungen auf erprobte Bibliotheken wie TanStack Query, SWR oder Relay verlassen. Sie kümmern sich um Caching, Deduplizierung und andere Komplexitäten und bieten gleichzeitig eine nahtlose Suspense-Integration.
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.