React Suspense: Gelernte Lektionen beim Laden von Daten

Avatar of Adam Rackis
Adam Rackis am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Suspense ist ein kommendes Feature von React, das hilft, asynchrone Aktionen – wie das Laden von Daten – zu koordinieren und somit einfach inkonsistente Zustände in Ihrer UI zu vermeiden. Ich werde eine bessere Erklärung dafür geben, was genau das bedeutet, zusammen mit einer kurzen Einführung in Suspense. Danach werde ich einen etwas realistischen Anwendungsfall durchgehen und einige gelernte Lektionen behandeln.

Die Features, die ich behandle, befinden sich noch im Alpha-Stadium und sollten auf keinen Fall in der Produktion verwendet werden. Dieser Beitrag richtet sich an Leute, die einen kleinen Vorgeschmack auf das Kommende erhalten und sehen möchten, wie die Zukunft aussieht.

Eine Suspense-Einführung

Einer der herausfordernderen Aspekte der Anwendungsentwicklung ist die Koordination des Anwendungszustands und der Datenladung. Es ist üblich, dass eine Zustandsänderung neue Datenladungen an mehreren Stellen auslöst. Typischerweise hätte jedes Datenteil seine eigene Lade-UI (wie einen "Spinner"), ungefähr dort, wo diese Daten in der Anwendung leben. Die asynchrone Natur der Datenladung bedeutet, dass jede dieser Anfragen in beliebiger Reihenfolge zurückgegeben werden kann. Infolgedessen werden nicht nur eine Menge verschiedener Spinner in Ihrer App ein- und ausblenden, sondern schlimmer noch, Ihre Anwendung könnte inkonsistente Daten anzeigen. Wenn zwei von drei Ihrer Datenladungen abgeschlossen sind, haben Sie einen Lade-Spinner über dem dritten Ort, der immer noch die alten, nun veralteten Daten anzeigt.

Ich weiß, das war viel. Wenn Sie etwas davon verwirrend finden, sind Sie vielleicht an einem früheren Beitrag von mir über Suspense interessiert. Dort wird detaillierter darauf eingegangen, was Suspense ist und was es leistet. Beachten Sie nur, dass einige kleinere Teile davon nun veraltet sind, nämlich, der Hook useTransition nimmt keinen timeoutMs Wert mehr entgegen und wartet stattdessen so lange wie nötig.

Nun werden wir die Details kurz durchgehen und uns dann einem spezifischen Anwendungsfall zuwenden, der einige lauernde Fallstricke birgt.

Wie funktioniert Suspense?

Glücklicherweise war das React-Team klug genug, diese Bemühungen nicht nur auf das Laden von Daten zu beschränken. Suspense funktioniert über Low-Level-Primitive, die Sie auf nahezu alles anwenden können. Werfen wir einen kurzen Blick auf diese Primitive.

Zuerst kommt die <Suspense>-Grenze, die eine fallback-Prop entgegennimmt

<Suspense fallback={<Fallback />}>

Immer wenn ein Kind-Element unter dieser Komponente suspendiert, rendert sie das Fallback. Egal, wie viele Kind-Elemente aus welchem Grund suspendieren, das Fallback ist das, was angezeigt wird. Das ist eine Methode, wie React eine konsistente UI sicherstellt – es rendert nichts, bis *alles* bereit ist.

Aber was ist mit der Zeit *nach* dem erstmaligen Rendern, wenn der Benutzer den Zustand ändert und neue Daten lädt? **Wir wollen sicherlich nicht, dass unsere bestehende UI verschwindet** und unser Fallback angezeigt wird; das wäre eine schlechte UX. Stattdessen möchten wir wahrscheinlich *einen* Lade-Spinner anzeigen, bis *alle* Daten bereit sind, und dann die neue UI anzeigen.

Der Hook useTransition leistet dies. Dieser Hook gibt eine Funktion und einen booleschen Wert zurück. Wir rufen die Funktion auf und umschließen unsere Zustandsänderungen. Nun wird es interessant. React versucht, unsere Zustandsänderung anzuwenden. Wenn etwas suspendiert, setzt React den booleschen Wert auf true und wartet dann, bis die Suspendierung endet. Wenn dies geschieht, versucht es, die Zustandsänderung erneut anzuwenden. Vielleicht gelingt es diesmal, oder vielleicht suspendiert stattdessen *etwas anderes*. Was auch immer der Fall ist, das boolesche Flag bleibt true, bis alles bereit ist, und erst dann wird die Zustandsänderung abgeschlossen und in der UI reflektiert.

Zuletzt, *wie* suspendieren wir? Wir suspendieren, indem wir ein Promise werfen. Wenn Daten angefordert werden und wir fetchen müssen, dann fetchen wir – und werfen ein Promise, das mit diesem Fetch verbunden ist. Da der Suspense-Mechanismus auf einer so niedrigen Ebene liegt, können wir ihn mit allem verwenden. Das React.lazy-Dienstprogramm zum verzögerten Laden von Komponenten funktioniert bereits mit Suspense, und ich habe bereits darüber geschrieben, wie Suspense verwendet wird, um zu warten, bis Bilder geladen sind, bevor eine UI angezeigt wird, um Verschiebungen des Inhalts zu verhindern.

Keine Sorge, wir werden uns das alles ansehen.

Was wir bauen

Wir werden etwas bauen, das sich geringfügig von den Beispielen vieler anderer Beiträge dieser Art unterscheidet. Denken Sie daran, Suspense ist noch im Alpha-Stadium, sodass Ihr bevorzugtes Datenladungsdienstprogramm wahrscheinlich noch keine Suspense-Unterstützung hat. Aber das bedeutet nicht, dass wir nicht ein paar Dinge fälschen und eine Vorstellung davon bekommen können, wie Suspense funktioniert.

Wir werden eine unendlich ladende Liste erstellen, die einige Daten anzeigt, kombiniert mit einigen Suspense-basierten vorab geladenen Bildern. Wir werden unsere Daten zusammen mit einer Schaltfläche zum Laden weiterer Daten anzeigen. Während Daten gerendert werden, laden wir das zugehörige Bild vor und suspendieren, bis es bereit ist.

Dieser Anwendungsfall basiert auf tatsächlicher Arbeit, die ich an meinem Nebenprojekt durchgeführt habe (nochmals, verwenden Sie Suspense nicht in der Produktion – aber Nebenprojekte sind fair), ich habe meinen eigenen GraphQL-Client verwendet, und dieser Beitrag wird durch einige der Schwierigkeiten motiviert, auf die ich gestoßen bin. Wir werden das Laden von Daten nur vortäuschen, um die Dinge einfach zu halten und uns auf Suspense selbst zu konzentrieren, anstatt auf einzelne Datenladungsdienstprogramme.

Lasst uns bauen!

Hier ist die Sandbox für unseren ersten Versuch. Wir werden sie verwenden, um alles durchzugehen, also fühlen Sie sich nicht unter Druck gesetzt, den gesamten Code sofort zu verstehen.

Unsere Wurzelkomponente App rendert eine Suspense-Grenze wie diese

<Suspense fallback={<Fallback />}>

Immer wenn etwas suspendiert (es sei denn, die Zustandsänderung erfolgte in einem useTransition-Aufruf), wird das Fallback gerendert. Um die Dinge leichter verfolgbar zu machen, habe ich diese Fallback-Komponente so gestaltet, dass sie die gesamte UI pink färbt. So ist sie schwer zu übersehen; unser Ziel ist es, Suspense zu verstehen, nicht eine qualitativ hochwertige UI zu bauen.

Wir laden den aktuellen Daten-Chunk innerhalb unserer DataList-Komponente

const newData = useQuery(param);

Unser useQuery-Hook ist hartcodiert, um gefälschte Daten zurückzugeben, einschließlich eines Timeouts, der eine Netzwerkanfrage simuliert. Er kümmert sich um das Caching der Ergebnisse und wirft ein Promise, wenn die Daten noch nicht gecacht sind.

Wir behalten (zumindest vorerst) den Zustand in der Masterliste der Daten, die wir anzeigen

const [data, setData] = useState([]);

Wenn neue Daten von unserem Hook kommen, hängen wir sie an unsere Masterliste an

useEffect(() => {
  setData((d) => d.concat(newData));
}, [newData]);

Schließlich, wenn der Benutzer mehr Daten möchte, klickt er auf die Schaltfläche, die dies aufruft

function loadMore() {
  startTransition(() => {
    setParam((x) => x + 1);
  });
}

Schließlich sei angemerkt, dass ich eine SuspenseImg-Komponente verwende, um das Bild vorzuladen, das ich mit jedem Datensatz anzeigen möchte. Es werden nur fünf zufällige Bilder angezeigt, aber ich füge eine Abfragezeichenfolge hinzu, um einen frischen Ladevorgang für jedes neue Datenelement zu gewährleisten, dem wir begegnen.

Zusammenfassung

Um zusammenzufassen, wo wir an diesem Punkt stehen: Wir haben einen Hook, der die aktuellen Daten lädt. Der Hook befolgt die Suspense-Mechanik und wirft ein Promise während des Ladevorgangs. Wann immer sich diese Daten ändern, wird die laufende Gesamtliste der Elemente aktualisiert und die neuen Elemente angehängt. Dies geschieht in useEffect. Jedes Element rendert ein Bild, und wir verwenden eine SuspenseImg-Komponente, um das Bild vorzuladen und zu suspendieren, bis es bereit ist. Wenn Sie neugierig sind, wie ein Teil dieses Codes funktioniert, schauen Sie sich meinen früheren Beitrag über das Vorladen von Bildern mit Suspense an.

Lasst uns testen

Das wäre ein ziemlich langweiliger Blogbeitrag, wenn alles funktionieren würde, und keine Sorge, das tut es nicht. Beachten Sie, wie beim erstmaligen Laden der rosafarbene Fallback-Bildschirm angezeigt und dann schnell ausgeblendet wird, aber dann wieder angezeigt wird.

Wenn wir auf die Schaltfläche klicken, die weitere Daten lädt, sehen wir, wie der Inline-Ladeindikator (gesteuert durch den Hook useTransition) auf true wechselt. Dann sehen wir ihn auf false wechseln, bevor unser ursprünglicher rosafarbener Fallback erscheint. Wir hatten erwartet, diesen rosafarbenen Bildschirm nach dem erstmaligen Laden nie wieder zu sehen; der Inline-Ladeindikator sollte anzeigen, bis alles bereit ist. Was ist los?

Das Problem

Es versteckt sich die ganze Zeit direkt hier, in aller Öffentlichkeit

useEffect(() => {
  setData((d) => d.concat(newData));
}, [newData]);

useEffect wird ausgeführt, wenn eine Zustandsänderung abgeschlossen ist, d. h. eine Zustandsänderung das Suspendieren beendet hat und auf das DOM angewendet wurde. Dieser Teil, "das Suspendieren beendet hat", ist hier entscheidend. Wir können hier bei Bedarf einen Zustand setzen, aber wenn diese Zustandsänderung suspendiert, ist das wieder eine brandneue Suspendierung. Deshalb haben wir beim erstmaligen Laden den rosafarbenen Blitz gesehen, ebenso wie bei nachfolgenden Ladevorgängen, wenn die Daten geladen waren. In beiden Fällen war das Laden der Daten abgeschlossen, und dann setzten wir in einem Effekt einen Zustand, der dazu führte, dass diese neuen Daten tatsächlich gerendert wurden und wegen des Vorladens der Bilder erneut suspendierten.

Wie beheben wir das also? Auf einer Ebene ist die Lösung einfach: Hören Sie auf, Zustände im Effekt zu setzen. Aber das ist leichter gesagt als getan. Wie aktualisieren wir unsere laufende Liste von Einträgen, um neue Ergebnisse hinzuzufügen, wenn sie eintreffen, ohne einen Effekt zu verwenden? Sie könnten denken, wir könnten Dinge mit einer Referenz verfolgen.

Leider bringt Suspense einige neue Regeln für Refs mit sich, nämlich können wir Refs nicht innerhalb eines Renderings setzen. Wenn Sie sich fragen, warum, denken Sie daran, dass bei Suspense versucht wird, ein Rendering auszuführen, ein Promise-Objekt zu sehen, das geworfen wird, und dann dieses Rendering auf halbem Weg abzubrechen. Wenn wir eine Ref mutieren würden, bevor dieses Rendering abgebrochen und verworfen wird, hätte die Ref immer noch diesen geänderten, aber ungültigen Wert. Die Renderfunktion muss rein sein, ohne Nebeneffekte. Das war schon immer eine Regel bei React, aber jetzt ist es wichtiger.

Überdenken unseres Datenladens

Hier ist die Lösung, die wir Stück für Stück durchgehen werden.

Zuerst, anstatt unsere Masterliste von Daten im Zustand zu speichern, tun wir etwas anderes: Wir speichern eine Liste von Seiten, die wir anzeigen. Wir können die aktuellste Seite in einer Ref speichern (wir werden sie jedoch nicht im Rendering schreiben) und wir speichern ein Array aller aktuell geladenen Seiten im Zustand.

const currentPage = useRef(0);
const [pages, setPages] = useState([currentPage.current]);

Um weitere Daten zu laden, werden wir entsprechend aktualisieren

function loadMore() {
  startTransition(() => {
    currentPage.current = currentPage.current + 1;
    setPages((pages) => pages.concat(currentPage.current));
  });
}

Der knifflige Teil ist jedoch, diese Seitenzahlen in tatsächliche Daten umzuwandeln. Was wir sicherlich *nicht* tun können, ist, über diese Seiten zu iterieren und unseren useQuery-Hook aufzurufen; Hooks können nicht in einer Schleife aufgerufen werden. Wir brauchen eine neue, Nicht-Hook-basierte Daten-API. Basierend auf einer sehr inoffiziellen Konvention, die ich in früheren Suspense-Demos gesehen habe, werde ich diese Methode read() nennen. Es wird kein Hook sein. Sie gibt die angeforderten Daten zurück, wenn sie gecacht sind, oder wirft andernfalls ein Promise. Für unseren gefälschten Datenladungs-Hook waren keine wirklichen Änderungen notwendig; ich habe den Hook einfach kopiert und umbenannt. Aber für ein echtes Datenladungsdienstprogramm müssen Autoren wahrscheinlich einige Arbeit leisten, um beide Optionen als Teil ihrer öffentlichen API bereitzustellen. In meinem oben erwähnten GraphQL-Client gibt es sowohl einen Hook useSuspenseQuery als auch eine read()-Methode auf dem Client-Objekt.

Mit dieser neuen read()-Methode ist der letzte Teil unseres Codes trivial

const data = pages.flatMap((page) => read(page));

Wir nehmen jede Seite und fordern die entsprechende Daten mit unserer read()-Methode an. Wenn eine der Seiten nicht gecacht ist (was eigentlich nur die letzte Seite in der Liste sein sollte), wird ein Promise geworfen und React suspendiert für uns. Wenn das Promise aufgelöst wird, versucht React die vorherige Zustandsänderung erneut, und dieser Code wird erneut ausgeführt.

Lassen Sie sich von dem flatMap-Aufruf nicht verwirren. Das leistet dasselbe wie map, außer dass es jedes Ergebnis im neuen Array nimmt und es "flacht", wenn es selbst ein Array ist.

Das Ergebnis

Mit diesen Änderungen funktionieren alle Dinge wie erwartet, als wir begannen. Unser rosafarbener Ladebildschirm wird einmal beim ersten Laden angezeigt, und dann, bei nachfolgenden Ladevorgängen, wird der Inline-Ladezustand angezeigt, bis alles bereit ist.

Abschließende Gedanken

Suspense ist ein aufregendes Update, das für React kommt. Es befindet sich noch im Alpha-Stadium, versuchen Sie es also nicht dort einzusetzen, wo es wichtig ist. Aber wenn Sie zu den Entwicklern gehören, die es genießen, einen kleinen Vorgeschmack auf kommende Dinge zu bekommen, dann hoffe ich, dass dieser Beitrag Ihnen einige gute Kontextinformationen und nützliche Hinweise für die Veröffentlichung liefert.