Entdecken Sie React Suspense für den Datenabruf jenseits von Code-Splitting. Verstehen Sie Fetch-As-You-Render, Fehlerbehandlung und zukunftssichere Muster für globale Anwendungen.
Ressourcenladen mit React Suspense: Moderne Datenabrufmuster meistern
In der dynamischen Welt der Webentwicklung ist die Benutzererfahrung (UX) das A und O. Anwendungen sollen schnell, reaktionsschnell und ansprechend sein, unabhängig von Netzwerkbedingungen oder Gerätefähigkeiten. Für React-Entwickler bedeutet dies oft eine komplizierte Zustandsverwaltung, komplexe Ladeindikatoren und einen ständigen Kampf gegen Datenabruf-Wasserfälle. Hier kommt React Suspense ins Spiel, ein leistungsstarkes, wenn auch oft missverstandenes Feature, das die Art und Weise, wie wir mit asynchronen Operationen, insbesondere dem Datenabruf, umgehen, grundlegend verändern soll.
Ursprünglich für das Code-Splitting mit React.lazy()
eingeführt, liegt das wahre Potenzial von Suspense in seiner Fähigkeit, das Laden *jeder* asynchronen Ressource zu orchestrieren, einschließlich Daten von einer API. Dieser umfassende Leitfaden wird tief in React Suspense für das Laden von Ressourcen eintauchen und seine Kernkonzepte, grundlegenden Datenabrufmuster und praktischen Überlegungen für den Aufbau performanter und widerstandsfähiger globaler Anwendungen untersuchen.
Die Evolution des Datenabrufs in React: Von Imperativ zu Deklarativ
Viele Jahre lang basierte der Datenabruf in React-Komponenten hauptsächlich auf einem gängigen Muster: die Verwendung des useEffect
-Hooks zum Initiieren eines API-Aufrufs, die Verwaltung von Lade- und Fehlerzuständen mit useState
und das bedingte Rendern basierend auf diesen Zuständen. Obwohl funktional, führte dieser Ansatz oft zu mehreren Herausforderungen:
- Verbreitung von Ladezuständen: Fast jede Komponente, die Daten benötigte, brauchte ihre eigenen
isLoading
-,isError
- unddata
-Zustände, was zu sich wiederholendem Boilerplate-Code führte. - Wasserfälle und Race Conditions: Verschachtelte Komponenten, die Daten abrufen, führten oft zu sequenziellen Anfragen (Wasserfällen), bei denen eine übergeordnete Komponente Daten abrief, dann renderte, dann eine untergeordnete Komponente ihre Daten abrief und so weiter. Dies erhöhte die Gesamt-Ladezeiten. Race Conditions konnten auch auftreten, wenn mehrere Anfragen initiiert wurden und die Antworten in falscher Reihenfolge eintrafen.
- Komplexe Fehlerbehandlung: Die Verteilung von Fehlermeldungen und Wiederherstellungslogik über zahlreiche Komponenten konnte umständlich sein und erforderte Prop-Drilling oder globale Zustandsverwaltungslösungen.
- Unangenehme Benutzererfahrung: Mehrere auf- und abtauchende Ladeindikatoren oder plötzliche Inhaltsverschiebungen (Layout Shifts) konnten für Benutzer eine störende Erfahrung schaffen.
- Prop-Drilling für Daten und Zustand: Die Weitergabe von abgerufenen Daten und zugehörigen Lade-/Fehlerzuständen über mehrere Komponentenebenen hinweg wurde zu einer häufigen Quelle von Komplexität.
Betrachten wir ein typisches Szenario für den Datenabruf ohne Suspense:
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(() => {
const fetchUser = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
setError(e);
} finally {
setIsLoading(false);
}
};
fetchUser();
}, [userId]);
if (isLoading) {
return <p>Benutzerprofil wird geladen...</p>;
}
if (error) {
return <p style={"color: red;"}>Fehler: {error.message}</p>;
}
if (!user) {
return <p>Keine Benutzerdaten verfügbar.</p>;
}
return (
<div>
<h2>Benutzer: {user.name}</h2>
<p>E-Mail: {user.email}</p>
<!-- Weitere Benutzerdetails -->
</div>
);
}
function App() {
return (
<div>
<h1>Willkommen in der Anwendung</h1>
<UserProfile userId={"123"} />
</div>
);
}
Dieses Muster ist allgegenwärtig, zwingt die Komponente jedoch, ihren eigenen asynchronen Zustand zu verwalten, was oft zu einer eng gekoppelten Beziehung zwischen der UI und der Datenabrufslogik führt. Suspense bietet eine deklarativere und optimierte Alternative.
React Suspense jenseits von Code-Splitting verstehen
Die meisten Entwickler begegnen Suspense erstmals durch React.lazy()
für das Code-Splitting, bei dem es ermöglicht wird, das Laden des Codes einer Komponente aufzuschieben, bis sie benötigt wird. Zum Beispiel:
import React, { Suspense, lazy } from 'react';
const LazyComponent = lazy(() => import('./MyHeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Komponente wird geladen...</div>}>
<LazyComponent />
</Suspense>
);
}
In diesem Szenario fängt die <Suspense>
-Grenze das von lazy()
geworfene Promise ab, wenn MyHeavyComponent
noch nicht geladen wurde, und zeigt das fallback
an, bis der Code der Komponente bereit ist. Die entscheidende Erkenntnis hier ist, dass Suspense funktioniert, indem es Promises abfängt, die während des Renderns geworfen werden.
Dieser Mechanismus ist nicht exklusiv für das Laden von Code. Jede Funktion, die während des Renderns aufgerufen wird und ein Promise wirft (z. B. weil eine Ressource noch nicht verfügbar ist), kann von einer Suspense-Grenze weiter oben im Komponentenbaum abgefangen werden. Wenn das Promise aufgelöst wird, versucht React, die Komponente neu zu rendern, und wenn die Ressource nun verfügbar ist, wird das Fallback ausgeblendet und der eigentliche Inhalt angezeigt.
Kernkonzepte von Suspense für den Datenabruf
Um Suspense für den Datenabruf zu nutzen, müssen wir einige Kernprinzipien verstehen:
1. Ein Promise werfen
Im Gegensatz zu traditionellem asynchronem Code, der async/await
verwendet, um Promises aufzulösen, verlässt sich Suspense auf eine Funktion, die ein Promise *wirft*, wenn die Daten nicht bereit sind. Wenn React versucht, eine Komponente zu rendern, die eine solche Funktion aufruft, und die Daten noch ausstehen, wird das Promise geworfen. React 'pausiert' dann das Rendern dieser Komponente und ihrer Kinder und sucht nach der nächsten <Suspense>
-Grenze.
2. Die Suspense-Grenze
Die <Suspense>
-Komponente fungiert als Fehlergrenze (Error Boundary) für Promises. Sie nimmt ein fallback
-Prop entgegen, welches die Benutzeroberfläche ist, die gerendert wird, während eines ihrer Kinder (oder deren Nachkommen) suspendiert (d.h. ein Promise wirft). Sobald alle in ihrem Unterbaum geworfenen Promises aufgelöst sind, wird das Fallback durch den eigentlichen Inhalt ersetzt.
Eine einzelne Suspense-Grenze kann mehrere asynchrone Operationen verwalten. Wenn Sie beispielsweise zwei Komponenten innerhalb derselben <Suspense>
-Grenze haben und jede Daten abrufen muss, wird das Fallback angezeigt, bis *beide* Datenabrufe abgeschlossen sind. Dies vermeidet die Anzeige einer unvollständigen Benutzeroberfläche und sorgt für eine koordiniertere Ladeerfahrung.
3. Der Cache/Ressourcen-Manager (Verantwortung des Userland)
Entscheidend ist, dass Suspense selbst weder den Datenabruf noch das Caching übernimmt. Es ist lediglich ein Koordinationsmechanismus. Um Suspense für den Datenabruf nutzbar zu machen, benötigen Sie eine Schicht, die:
- Den Datenabruf initiiert.
- Das Ergebnis (aufgelöste Daten oder ausstehendes Promise) zwischenspeichert.
- Eine synchrone
read()
-Methode bereitstellt, die entweder die zwischengespeicherten Daten sofort zurückgibt (falls verfügbar) oder das ausstehende Promise wirft (falls nicht).
Dieser 'Ressourcen-Manager' wird typischerweise mit einem einfachen Cache (z.B. einer Map oder einem Objekt) implementiert, um den Zustand jeder Ressource (ausstehend, aufgelöst oder fehlerhaft) zu speichern. Während Sie dies zu Demonstrationszwecken manuell erstellen können, würden Sie in einer realen Anwendung eine robuste Datenabrufsbibliothek verwenden, die sich in Suspense integriert.
4. Concurrent Mode (Verbesserungen in React 18)
Obwohl Suspense in älteren Versionen von React verwendet werden kann, wird seine volle Leistungsfähigkeit mit Concurrent React entfesselt (standardmäßig in React 18 mit createRoot
aktiviert). Der Concurrent Mode ermöglicht es React, Render-Arbeiten zu unterbrechen, zu pausieren und wieder aufzunehmen. Das bedeutet:
- Nicht-blockierende UI-Updates: Wenn Suspense ein Fallback anzeigt, kann React weiterhin andere Teile der UI rendern, die nicht suspendiert sind, oder sogar die neue UI im Hintergrund vorbereiten, ohne den Hauptthread zu blockieren.
- Transitions: Neue APIs wie
useTransition
ermöglichen es Ihnen, bestimmte Updates als 'Transitions' zu markieren, die React unterbrechen und als weniger dringend behandeln kann, was zu flüssigeren UI-Änderungen während des Datenabrufs führt.
Datenabrufmuster mit Suspense
Lassen Sie uns die Entwicklung der Datenabrufmuster mit dem Aufkommen von Suspense untersuchen.
Pattern 1: Fetch-Then-Render (Traditionell mit Suspense-Wrapper)
Dies ist der klassische Ansatz, bei dem Daten abgerufen werden und erst dann die Komponente gerendert wird. Obwohl der 'Throw-Promise'-Mechanismus nicht direkt für Daten genutzt wird, können Sie eine Komponente, die *schließlich* Daten rendert, in eine Suspense-Grenze einwickeln, um ein Fallback bereitzustellen. Hierbei geht es mehr darum, Suspense als generischen Lade-UI-Orchestrator für Komponenten zu verwenden, die irgendwann bereit sind, auch wenn ihr interner Datenabruf noch traditionell auf useEffect
basiert.
import React, { Suspense, useState, useEffect } from 'react';
function UserDetails({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchUserData = async () => {
setIsLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
setIsLoading(false);
};
fetchUserData();
}, [userId]);
if (isLoading) {
return <p>Benutzerdetails werden geladen...</p>;
}
return (
<div>
<h3>Benutzer: {user.name}</h3>
<p>E-Mail: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Fetch-Then-Render Beispiel</h1>
<Suspense fallback={<div>Gesamte Seite wird geladen...</div>}>
<UserDetails userId={"1"} />
</Suspense>
</div>
);
}
Vorteile: Einfach zu verstehen, abwärtskompatibel. Kann als schneller Weg verwendet werden, um einen globalen Ladezustand hinzuzufügen.
Nachteile: Beseitigt nicht den Boilerplate-Code innerhalb von UserDetails
. Immer noch anfällig für Wasserfälle, wenn Komponenten Daten sequenziell abrufen. Nutzt den 'Throw-and-Catch'-Mechanismus von Suspense für die Daten selbst nicht wirklich.
Pattern 2: Render-Then-Fetch (Abruf innerhalb des Renderings, nicht für die Produktion)
Dieses Muster dient hauptsächlich dazu zu veranschaulichen, was man mit Suspense direkt nicht tun sollte, da es ohne sorgfältige Handhabung zu Endlosschleifen oder Leistungsproblemen führen kann. Es beinhaltet den Versuch, Daten abzurufen oder eine suspendierende Funktion direkt in der Render-Phase einer Komponente aufzurufen, *ohne* einen richtigen Caching-Mechanismus.
// DIESEN CODE NICHT OHNE EINE KORREKTE CACHING-SCHICHT IN DER PRODUKTION VERWENDEN
// Dies dient nur zur Veranschaulichung, wie ein direkter 'throw' konzeptionell funktionieren könnte.
let fetchedData = null;
let dataPromise = null;
function fetchDataSynchronously(url) {
if (fetchedData) {
return fetchedData;
}
if (!dataPromise) {
dataPromise = fetch(url)
.then(res => res.json())
.then(data => { fetchedData = data; dataPromise = null; return data; })
.catch(err => { dataPromise = null; throw err; });
}
throw dataPromise; // Hier greift Suspense ein
}
function UserDetailsBadExample({ userId }) {
const user = fetchDataSynchronously(`/api/users/${userId}`);
return (
<div>
<h3>Benutzer: {user.name}</h3>
<p>E-Mail: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Render-Then-Fetch (Illustrativ, NICHT direkt empfohlen)</h1>
<Suspense fallback={<div>Benutzer wird geladen...</div>}>
<UserDetailsBadExample userId={"2"} />
</Suspense>
</div>
);
}
Vorteile: Zeigt, wie eine Komponente direkt nach Daten 'fragen' und suspendieren kann, wenn sie nicht bereit sind.
Nachteile: Sehr problematisch für die Produktion. Dieses manuelle, globale fetchedData
- und dataPromise
-System ist vereinfacht, handhabt keine multiplen Anfragen, Invalidierung oder Fehlerzustände robust. Es ist eine primitive Veranschaulichung des 'Throw-a-Promise'-Konzepts, kein Muster, das man übernehmen sollte.
Pattern 3: Fetch-As-You-Render (Das ideale Suspense-Muster)
Dies ist der Paradigmenwechsel, den Suspense wirklich für den Datenabruf ermöglicht. Anstatt darauf zu warten, dass eine Komponente rendert, bevor ihre Daten abgerufen werden, oder alle Daten im Voraus abzurufen, bedeutet Fetch-As-You-Render, dass Sie mit dem Abrufen von Daten *so früh wie möglich* beginnen, oft *vor* oder *gleichzeitig mit* dem Render-Prozess. Komponenten 'lesen' dann die Daten aus einem Cache, und wenn die Daten nicht bereit sind, suspendieren sie. Die Kernidee besteht darin, die Datenabrufslogik von der Render-Logik der Komponente zu trennen.
Um Fetch-As-You-Render zu implementieren, benötigen Sie einen Mechanismus, um:
- Einen Datenabruf außerhalb der Render-Funktion der Komponente zu initiieren (z. B. wenn eine Route betreten oder ein Button geklickt wird).
- Das Promise oder die aufgelösten Daten in einem Cache zu speichern.
- Eine Möglichkeit für Komponenten bereitzustellen, aus diesem Cache zu 'lesen'. Wenn die Daten noch nicht verfügbar sind, wirft die Lese-Funktion das ausstehende Promise.
Dieses Muster löst das Wasserfallproblem. Wenn zwei verschiedene Komponenten Daten benötigen, können ihre Anfragen parallel initiiert werden, und die Benutzeroberfläche erscheint erst, wenn *beide* bereit sind, orchestriert von einer einzigen Suspense-Grenze.
Manuelle Implementierung (zum Verständnis)
Um die zugrunde liegenden Mechanismen zu verstehen, erstellen wir einen vereinfachten manuellen Ressourcen-Manager. In einer realen Anwendung würden Sie eine dedizierte Bibliothek verwenden.
import React, { Suspense } from 'react';
// --- Einfacher Cache/Ressourcen-Manager --- //
const cache = new Map();
function createResource(promise) {
let status = 'pending';
let result;
let suspender = promise.then(
(r) => {
status = 'success';
result = r;
},
(e) => {
status = 'error';
result = e;
}
);
return {
read() {
if (status === 'pending') {
throw suspender;
} else if (status === 'error') {
throw result;
} else if (status === 'success') {
return result;
}
},
};
}
function fetchData(key, fetcher) {
if (!cache.has(key)) {
cache.set(key, createResource(fetcher()));
}
return cache.get(key);
}
// --- Datenabruffunktionen --- //
const fetchUserById = (id) => {
console.log(`Rufe Benutzer ${id} ab...`);
return new Promise(resolve => setTimeout(() => {
const users = {
'1': { id: '1', name: 'Alice Schmidt', email: 'alice@example.com' },
'2': { id: '2', name: 'Bob Johnson', email: 'bob@example.com' },
'3': { id: '3', name: 'Charlie Braun', email: 'charlie@example.com' }
};
resolve(users[id]);
}, 1500));
};
const fetchPostsByUserId = (userId) => {
console.log(`Rufe Beiträge für Benutzer ${userId} ab...`);
return new Promise(resolve => setTimeout(() => {
const posts = {
'1': [{ id: 'p1', title: 'Mein erster Beitrag' }, { id: 'p2', title: 'Reiseabenteuer' }],
'2': [{ id: 'p3', title: 'Einblicke ins Programmieren' }],
'3': [{ id: 'p4', title: 'Globale Trends' }, { id: 'p5', title: 'Lokale Küche' }]
};
resolve(posts[userId] || []);
}, 2000));
};
// --- Komponenten --- //
function UserProfile({ userId }) {
const userResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
const user = userResource.read(); // Dies suspendiert, wenn Benutzerdaten nicht bereit sind
return (
<div>
<h3>Benutzer: {user.name}</h3>
<p>E-Mail: {user.email}</p>
</div>
);
}
function UserPosts({ userId }) {
const postsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
const posts = postsResource.read(); // Dies suspendiert, wenn Beitragsdaten nicht bereit sind
return (
<div>
<h4>Beiträge von {userId}:</h4>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
{posts.length === 0 && <li>Keine Beiträge gefunden.</li>}
</ul>
</div>
);
}
// --- Anwendung --- //
let initialUserResource = null;
let initialPostsResource = null;
function prefetchDataForUser(userId) {
initialUserResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
initialPostsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
}
// Daten vorab abrufen, bevor die App-Komponente überhaupt rendert
prefetchDataForUser('1');
function App() {
return (
<div>
<h1>Fetch-As-You-Render mit Suspense</h1>
<p>Dies demonstriert, wie der Datenabruf parallel erfolgen kann, koordiniert durch Suspense.</p>
<Suspense fallback={<div>Lade Benutzerprofil und Beiträge...</div>}>
<UserProfile userId={"1"} />
<UserPosts userId={"1"} />
</Suspense>
<h2>Ein anderer Abschnitt</h2>
<Suspense fallback={<div>Lade anderen Benutzer...</div>}>
<UserProfile userId={"2"} />
</Suspense>
</div>
);
}
In diesem Beispiel:
- Die Funktionen
createResource
undfetchData
richten einen grundlegenden Caching-Mechanismus ein. - Wenn
UserProfile
oderUserPosts
resource.read()
aufrufen, erhalten sie entweder sofort die Daten oder das Promise wird geworfen. - Die nächste
<Suspense>
-Grenze fängt das/die Promise(s) ab und zeigt ihr Fallback an. - Entscheidend ist, dass wir
prefetchDataForUser('1')
aufrufen können, *bevor* dieApp
-Komponente rendert, was es ermöglicht, den Datenabruf noch früher zu starten.
Bibliotheken für Fetch-As-You-Render
Einen robusten Ressourcen-Manager manuell zu erstellen und zu pflegen ist komplex. Glücklicherweise haben mehrere ausgereifte Datenabrufsbibliotheken Suspense übernommen oder sind dabei, dies zu tun, und bieten praxiserprobte Lösungen:
- React Query (TanStack Query): Bietet eine leistungsstarke Datenabruf- und Caching-Schicht mit Suspense-Unterstützung. Es stellt Hooks wie
useQuery
zur Verfügung, die suspendieren können. Es ist hervorragend für REST-APIs geeignet. - SWR (Stale-While-Revalidate): Eine weitere beliebte und leichtgewichtige Datenabrufsbibliothek, die Suspense vollständig unterstützt. Ideal für REST-APIs, konzentriert sie sich darauf, Daten schnell (veraltet) bereitzustellen und sie dann im Hintergrund neu zu validieren.
- Apollo Client: Ein umfassender GraphQL-Client, der eine robuste Suspense-Integration für GraphQL-Queries und -Mutationen bietet.
- Relay: Facebooks eigener GraphQL-Client, der von Grund auf für Suspense und Concurrent React entwickelt wurde. Er erfordert ein spezifisches GraphQL-Schema und einen Kompilierungsschritt, bietet aber eine unübertroffene Leistung und Datenkonsistenz.
- Urql: Ein leichtgewichtiger und hochgradig anpassbarer GraphQL-Client mit Suspense-Unterstützung.
Diese Bibliotheken abstrahieren die Komplexität der Erstellung und Verwaltung von Ressourcen, des Cachings, der Revalidierung, optimistischer Updates und der Fehlerbehandlung, was die Implementierung von Fetch-As-You-Render erheblich erleichtert.
Pattern 4: Prefetching mit Suspense-fähigen Bibliotheken
Prefetching ist eine leistungsstarke Optimierung, bei der Sie proaktiv Daten abrufen, die ein Benutzer wahrscheinlich in naher Zukunft benötigen wird, bevor er sie überhaupt explizit anfordert. Dies kann die wahrgenommene Leistung drastisch verbessern.
Mit Suspense-fähigen Bibliotheken wird das Prefetching nahtlos. Sie können Datenabrufe bei Benutzerinteraktionen auslösen, die die Benutzeroberfläche nicht sofort ändern, wie z.B. das Schweben über einem Link oder das Bewegen der Maus über einen Button.
import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// Annahme, dies sind Ihre API-Aufrufe
const fetchProductById = async (id) => {
console.log(`Rufe Produkt ${id} ab...`);
return new Promise(resolve => setTimeout(() => {
const products = {
'A001': { id: 'A001', name: 'Global Widget X', price: 29.99, description: 'Ein vielseitiges Widget für den internationalen Einsatz.' },
'B002': { id: 'B002', name: 'Universal Gadget Y', price: 149.99, description: 'Modernstes Gadget, weltweit beliebt.' },
};
resolve(products[id]);
}, 1000));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // Suspense für alle Queries standardmäßig aktivieren
},
},
});
function ProductDetails({ productId }) {
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
return (
<div style={{\"border\": \"1px solid #ccc\", \"padding\": \"15px\", \"margin\": \"10px 0\"}}>
<h3>{product.name}</h3>
<p>Preis: ${product.price.toFixed(2)}</p>
<p>{product.description}</p>
</div>
);
}
function ProductList() {
const handleProductHover = (productId) => {
// Daten vorab abrufen, wenn ein Benutzer über einen Produktlink schwebt
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
console.log(`Prefetching für Produkt ${productId}`);
};
return (
<div>
<h2>Verfügbare Produkte:</h2>
<ul>
<li>
<a href=\"#\" onMouseEnter={() => handleProductHover('A001')}
onClick={(e) => { e.preventDefault(); /* Navigieren oder Details anzeigen */ }}
>Global Widget X (A001)</a>
</li>
<li>
<a href=\"#\" onMouseEnter={() => handleProductHover('B002')}
onClick={(e) => { e.preventDefault(); /* Navigieren oder Details anzeigen */ }}
>Universal Gadget Y (B002)</a>
</li>
</ul>
<p>Fahren Sie mit der Maus über einen Produktlink, um das Prefetching in Aktion zu sehen. Öffnen Sie den Netzwerk-Tab zur Beobachtung.</p>
</div>
);
}
function App() {
const [showProductA, setShowProductA] = React.useState(false);
const [showProductB, setShowProductB] = React.useState(false);
return (
<QueryClientProvider client={queryClient}>
<h1>Prefetching mit React Suspense (React Query)</h1>
<ProductList />
<button onClick={() => setShowProductA(true)}>Global Widget X anzeigen</button>
<button onClick={() => setShowProductB(true)}>Universal Gadget Y anzeigen</button>
{showProductA && (
<Suspense fallback={<p>Lade Global Widget X...</p>}>
<ProductDetails productId=\"A001\" />
</Suspense>
)}
{showProductB && (
<Suspense fallback={<p>Lade Universal Gadget Y...</p>}>
<ProductDetails productId=\"B002\" />
</Suspense>
)}
</QueryClientProvider>
);
}
In diesem Beispiel löst das Schweben über einem Produktlink `queryClient.prefetchQuery` aus, was den Datenabruf im Hintergrund initiiert. Wenn der Benutzer dann auf den Button klickt, um die Produktdetails anzuzeigen, und die Daten aus dem Prefetch bereits im Cache sind, wird die Komponente sofort rendern, ohne zu suspendieren. Wenn der Prefetch noch läuft oder nicht initiiert wurde, zeigt Suspense das Fallback an, bis die Daten bereit sind.
Fehlerbehandlung mit Suspense und Error Boundaries
Während Suspense den 'Lade'-Zustand durch Anzeigen eines Fallbacks handhabt, behandelt es nicht direkt 'Fehler'-Zustände. Wenn ein von einer suspendierenden Komponente geworfenes Promise ablehnt (d.h. der Datenabruf fehlschlägt), wird dieser Fehler den Komponentenbaum hinaufpropagiert. Um diese Fehler elegant zu behandeln und eine angemessene Benutzeroberfläche anzuzeigen, müssen Sie Error Boundaries verwenden.
Eine Error Boundary ist eine React-Komponente, die entweder die Lebenszyklusmethoden componentDidCatch
oder static getDerivedStateFromError
implementiert. Sie fängt JavaScript-Fehler überall in ihrem untergeordneten Komponentenbaum ab, einschließlich Fehlern, die von Promises geworfen werden, die Suspense normalerweise abfangen würde, wenn sie ausstehend wären.
import React, { Suspense, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// --- Error Boundary Komponente --- //
class MyErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Zustand aktualisieren, damit das nächste Rendering die Fallback-UI anzeigt.
return { hasError: true, 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 benutzerdefinierte Fallback-UI rendern
return (
<div style={{\"border\": \"2px solid red\", \"padding\": \"20px\", \"margin\": \"20px 0\", \"background\": \"#ffe0e0\"}}>
<h2>Etwas ist schiefgelaufen!</h2>
<p>{this.state.error && this.state.error.message}</p>
<p>Bitte versuchen Sie, die Seite neu zu laden, oder kontaktieren Sie den Support.</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>Erneut versuchen</button>
</div>
);
}
return this.props.children;
}
}
// --- Datenabruf (mit Fehlerpotenzial) --- //
const fetchItemById = async (id) => {
console.log(`Versuche, Element ${id} abzurufen...`);
return new Promise((resolve, reject) => setTimeout(() => {
if (id === 'error-item') {
reject(new Error('Element konnte nicht geladen werden: Netzwerk nicht erreichbar oder Element nicht gefunden.'));
} else if (id === 'slow-item') {
resolve({ id: 'slow-item', name: 'Langsam geliefert', data: 'Dieses Element hat eine Weile gedauert, ist aber angekommen!', status: 'success' });
} else {
resolve({ id, name: `Element ${id}`, data: `Daten für Element ${id}` });
}
}, id === 'slow-item' ? 3000 : 800));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
retry: false, // Für die Demonstration Wiederholungsversuche deaktivieren, damit der Fehler sofort auftritt
},
},
});
function DisplayItem({ itemId }) {
const { data: item } = useQuery({
queryKey: ['item', itemId],
queryFn: () => fetchItemById(itemId),
});
return (
<div>
<h3>Elementdetails:</h3>
<p>ID: {item.id}</p>
<p>Name: {item.name}</p>
<p>Daten: {item.data}</p>
</div>
);
}
function App() {
const [fetchType, setFetchType] = useState('normal-item');
return (
<QueryClientProvider client={queryClient}>
<h1>Suspense und Error Boundaries</h1>
<div>
<button onClick={() => setFetchType('normal-item')}>Normales Element abrufen</button>
<button onClick={() => setFetchType('slow-item')}>Langsames Element abrufen</button>
<button onClick={() => setFetchType('error-item')}>Fehlerhaftes Element abrufen</button>
</div>
<MyErrorBoundary>
<Suspense fallback={<p>Lade Element via Suspense...</p>}>
<DisplayItem itemId={fetchType} />
</Suspense>
</MyErrorBoundary>
</QueryClientProvider>
);
}
Indem Sie Ihre Suspense-Grenze (oder die Komponenten, die suspendieren könnten) mit einer Error Boundary umwickeln, stellen Sie sicher, dass Netzwerkfehler oder Serverfehler während des Datenabrufs abgefangen und elegant behandelt werden, was verhindert, dass die gesamte Anwendung abstürzt. Dies bietet eine robuste und benutzerfreundliche Erfahrung, die es den Benutzern ermöglicht, das Problem zu verstehen und es möglicherweise erneut zu versuchen.
Zustandsverwaltung und Dateninvalidierung mit Suspense
Es ist wichtig zu verdeutlichen, dass React Suspense hauptsächlich den anfänglichen Ladezustand asynchroner Ressourcen adressiert. Es verwaltet nicht inhärent den clientseitigen Cache, handhabt die Dateninvalidierung oder orchestriert Mutationen (Erstellungs-, Aktualisierungs-, Löschoperationen) und deren nachfolgende UI-Aktualisierungen.
Hier werden die Suspense-fähigen Datenabrufsbibliotheken (React Query, SWR, Apollo Client, Relay) unverzichtbar. Sie ergänzen Suspense durch die Bereitstellung von:
- Robustes Caching: Sie unterhalten einen ausgeklügelten In-Memory-Cache von abgerufenen Daten, servieren ihn sofort, wenn verfügbar, und kümmern sich um die Hintergrund-Revalidierung.
- Dateninvalidierung und erneutes Abrufen: Sie bieten Mechanismen, um zwischengespeicherte Daten als 'veraltet' zu markieren und sie erneut abzurufen (z.B. nach einer Mutation, einer Benutzerinteraktion oder bei Fensterfokus).
- Optimistische Updates: Bei Mutationen ermöglichen sie es Ihnen, die Benutzeroberfläche sofort (optimistisch) basierend auf dem erwarteten Ergebnis eines API-Aufrufs zu aktualisieren und dann zurückzusetzen, wenn der tatsächliche API-Aufruf fehlschlägt.
- Globale Zustandssynchronisation: Sie stellen sicher, dass alle Komponenten, die Daten anzeigen, automatisch aktualisiert werden, wenn sich Daten in einem Teil Ihrer Anwendung ändern.
- Lade- und Fehlerzustände für Mutationen: Während
useQuery
möglicherweise suspendiert, stelltuseMutation
typischerweiseisLoading
- undisError
-Zustände für den Mutationsprozess selbst bereit, da Mutationen oft interaktiv sind und sofortiges Feedback erfordern.
Ohne eine robuste Datenabrufsbibliothek wäre die Implementierung dieser Funktionen zusätzlich zu einem manuellen Suspense-Ressourcen-Manager ein erhebliches Unterfangen, das im Wesentlichen den Aufbau eines eigenen Datenabrufs-Frameworks erfordern würde.
Praktische Überlegungen und Best Practices
Die Einführung von Suspense für den Datenabruf ist eine wichtige architektonische Entscheidung. Hier sind einige praktische Überlegungen für eine globale Anwendung:
1. Nicht alle Daten benötigen Suspense
Suspense ist ideal für kritische Daten, die das anfängliche Rendern einer Komponente direkt beeinflussen. Für nicht-kritische Daten, Hintergrundabrufe oder Daten, die ohne starke visuelle Auswirkungen verzögert geladen werden können, könnte traditionelles useEffect
oder Pre-Rendering immer noch geeignet sein. Eine übermäßige Verwendung von Suspense kann zu einer weniger granularen Ladeerfahrung führen, da eine einzelne Suspense-Grenze darauf wartet, dass *alle* ihre Kinder aufgelöst sind.
2. Granularität der Suspense-Grenzen
Platzieren Sie Ihre <Suspense>
-Grenzen überlegt. Eine einzige, große Grenze an der Spitze Ihrer Anwendung könnte die gesamte Seite hinter einem Ladeindikator verbergen, was frustrierend sein kann. Kleinere, granularere Grenzen ermöglichen es verschiedenen Teilen Ihrer Seite, unabhängig voneinander zu laden, was eine progressivere und reaktionsschnellere Erfahrung bietet. Zum Beispiel eine Grenze um eine Benutzerprofilkomponente und eine weitere um eine Liste empfohlener Produkte.
<div>
<h1>Produktseite</h1>
<Suspense fallback={<p>Lade Hauptproduktdetails...</p>}>
<ProductDetails id=\"prod123\" />
</Suspense>
<hr />
<h2>Ähnliche Produkte</h2>
<Suspense fallback={<p>Lade ähnliche Produkte...</p>}>
<RelatedProducts category=\"electronics\" />
</Suspense>
</div>
Dieser Ansatz bedeutet, dass Benutzer die Hauptproduktdetails sehen können, auch wenn die ähnlichen Produkte noch geladen werden.
3. Server-Side Rendering (SSR) und Streaming-HTML
Die neuen Streaming-SSR-APIs von React 18 (renderToPipeableStream
) sind vollständig in Suspense integriert. Dies ermöglicht es Ihrem Server, HTML zu senden, sobald es bereit ist, auch wenn Teile der Seite (wie datenabhängige Komponenten) noch geladen werden. Der Server kann einen Platzhalter (aus dem Suspense-Fallback) streamen und dann den eigentlichen Inhalt streamen, wenn die Daten aufgelöst sind, ohne ein vollständiges clientseitiges Neu-Rendern zu erfordern. Dies verbessert die wahrgenommene Ladeleistung für globale Benutzer mit unterschiedlichen Netzwerkbedingungen erheblich.
4. Inkrementelle Einführung
Sie müssen nicht Ihre gesamte Anwendung umschreiben, um Suspense zu verwenden. Sie können es schrittweise einführen, beginnend mit neuen Funktionen oder Komponenten, die am meisten von seinen deklarativen Lademustern profitieren würden.
5. Werkzeuge und Debugging
Obwohl Suspense die Komponentenlogik vereinfacht, kann das Debugging anders sein. Die React DevTools bieten Einblicke in Suspense-Grenzen und deren Zustände. Machen Sie sich damit vertraut, wie Ihre gewählte Datenabrufsbibliothek ihren internen Zustand offenlegt (z.B. React Query Devtools).
6. Timeouts für Suspense-Fallbacks
Bei sehr langen Ladezeiten möchten Sie möglicherweise ein Timeout für Ihr Suspense-Fallback einführen oder nach einer bestimmten Verzögerung zu einem detaillierteren Ladeindikator wechseln. Die Hooks useDeferredValue
und useTransition
in React 18 können helfen, diese nuancierteren Ladezustände zu verwalten, indem sie es Ihnen ermöglichen, eine 'alte' Version der Benutzeroberfläche anzuzeigen, während neue Daten abgerufen werden, oder nicht dringende Updates aufzuschieben.
Die Zukunft des Datenabrufs in React: React Server Components und darüber hinaus
Der Weg des Datenabrufs in React endet nicht mit clientseitigem Suspense. React Server Components (RSC) stellen eine bedeutende Entwicklung dar und versprechen, die Grenzen zwischen Client und Server zu verwischen und den Datenabruf weiter zu optimieren.
- React Server Components (RSC): Diese Komponenten rendern auf dem Server, rufen ihre Daten direkt ab und senden dann nur das notwendige HTML und clientseitige JavaScript an den Browser. Dies eliminiert clientseitige Wasserfälle, reduziert die Bundle-Größen und verbessert die anfängliche Ladeleistung. RSCs arbeiten Hand in Hand mit Suspense: Server-Komponenten können suspendieren, wenn ihre Daten nicht bereit sind, und der Server kann ein Suspense-Fallback an den Client streamen, das dann ersetzt wird, wenn die Daten aufgelöst sind. Dies ist ein Game-Changer für Anwendungen mit komplexen Datenanforderungen und bietet eine nahtlose und hochperformante Erfahrung, die besonders für Benutzer in verschiedenen geografischen Regionen mit unterschiedlicher Latenz vorteilhaft ist.
- Einheitlicher Datenabruf: Die langfristige Vision für React beinhaltet einen einheitlichen Ansatz zum Datenabruf, bei dem das Kernframework oder eng integrierte Lösungen erstklassige Unterstützung für das Laden von Daten sowohl auf dem Server als auch auf dem Client bieten, alles orchestriert durch Suspense.
- Fortgesetzte Bibliotheksentwicklung: Datenabrufsbibliotheken werden sich weiterentwickeln und noch ausgefeiltere Funktionen für Caching, Invalidierung und Echtzeit-Updates anbieten, die auf den grundlegenden Fähigkeiten von Suspense aufbauen.
Während React weiter reift, wird Suspense ein immer zentralerer Teil des Puzzles für den Bau hochperformanter, benutzerfreundlicher und wartbarer Anwendungen sein. Es treibt Entwickler zu einer deklarativeren und widerstandsfähigeren Art des Umgangs mit asynchronen Operationen und verlagert die Komplexität von einzelnen Komponenten in eine gut verwaltete Datenschicht.
Fazit
React Suspense, ursprünglich ein Feature für Code-Splitting, hat sich zu einem transformativen Werkzeug für den Datenabruf entwickelt. Durch die Übernahme des Fetch-As-You-Render-Musters und die Nutzung von Suspense-fähigen Bibliotheken können Entwickler die Benutzererfahrung ihrer Anwendungen erheblich verbessern, indem sie Lade-Wasserfälle eliminieren, die Komponentenlogik vereinfachen und reibungslose, koordinierte Ladezustände bereitstellen. In Kombination mit Error Boundaries für eine robuste Fehlerbehandlung und dem zukünftigen Versprechen von React Server Components ermöglicht uns Suspense, Anwendungen zu erstellen, die nicht nur performant und widerstandsfähig, sondern auch von Natur aus für Benutzer auf der ganzen Welt angenehmer sind. Der Wechsel zu einem Suspense-gesteuerten Datenabrufparadigma erfordert eine konzeptionelle Anpassung, aber die Vorteile in Bezug auf Code-Klarheit, Leistung und Benutzerzufriedenheit sind erheblich und die Investition wert.