Pobieranie równoległe w React: Suspense z Promise.all i allSettled
Dowiedz się, jak usunąć sekwencje zapytań za pomocą Promise.all, chronić dane opcjonalne przed awariami stron dzięki Promise.allSettled oraz wyświetlać stany ładowania za pomocą Suspense.
Strona, na której pojawia się kolejny spinner za drugim, zazwyczaj ma jeden z dwóch problemów. Albo jej żądania są wykonywane po kolei, mimo że mogłyby być realizowane jednocześnie, albo jakieś opcjonalne żądanie, np. widget z rekomendacjami, zawodzi i powoduje awarię całej strony. Trzy narzędzia rozwiązują oba te problemy: Promise.all i Promise.allSettled umożliwiają równoczesne wykonywanie zadań przy różnych strategiach radzenia sobie z awariami, natomiast Suspense w React służy do pokazywania alternatywy, dopóki dane nie zostaną dostarczone. Ten przewodnik omawia każde z nich przy użyciu małej demonstracji, która ładuje dwie listy z publicznego API.
Pochodzenie „wodospadów”
Oczekiwanie na połączenia jeden po drugim powoduje ich sekwencjalizację. Drugie żądanie nie rozpoczyna się nawet wtedy, gdy pierwsze zostało zakończone, więc całkowity czas oczekiwania to suma obu czasów:
const categories = await fetchCategories();
// fetchPosts will only start running after fetchCategories finishes executing.
const posts = await fetchPosts();
Gdy żądania nie są od siebie zależne, uruchom je oba najpierw i poczekaj na nie jednocześnie. Całkowity czas wtedy spada mniej więcej do wartości najwolniejszego żądania, zamiast być sumą czasów wszystkich. Aby uzyskać szersze porównanie tych mechanizmów, zapoznaj się z wyborem między Promise.all, Promise.race a sekwencyjnymi awaits.
Suspense: deklaratywne stany ładowania
Suspense otacza część struktury i wyświetla fallback, takiego jak szkielet lub komunikat „Ładowanie danych...”, podczas gdy wszystko, co znajduje się wewnątrz, czeka. Reszta strony pozostaje widoczna, a nie musisz już ręcznie tworzyć flagi loading dla tej sekcji.
W demonstracji funkcja asynchroniczna czeka sztucznie trzy sekundy, aby pokazać stan ładowania, a następnie uruchamia zaklęcia i żądania książek jednocześnie, łącząc je za pomocą Promise.all. Powstały obietnica jest przekazywana do Section1, który rozpakowuje ją za pomocą hooka use w React. Dopóki obietnica nie zostanie rozwiązana, use wstrzymuje działanie komponentu, a pokazuje się alternatywa.
'use client';
import React, { Suspense, use } from 'react';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function Home() {
const fetchDataParalel = async () => {
await delay(3000); // Simulate a 3-second loading delay
// Trigger both APIs in PARALLEL (simultaneously)
const getSpellsPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
).then((res) => res.json());
const getBooksPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
).then((res) => res.json());
// Wait for both to finish together
const [spellsData, booksData] = await Promise.all([
getSpellsPromise,
getBooksPromise,
]);
return {
spells: spellsData?.map((item: any) => item.spell) || [],
books: booksData?.map((item: any) => item.title) || [],
};
};
return (
<main className="p-4">
<p>
<strong>Demo Suspense + Promise.all</strong>
</p>
<br />
<Suspense fallback={<p>Loading data...</p>}>
<Section1 dataPromise={fetchDataParalel()} />
</Suspense>
</main>
);
}
function Section1({
dataPromise,
}: {
dataPromise: Promise<{ spells: string[]; books: string[] }>;
}) {
const data = use(dataPromise);
return (
<div>
<p>
<strong>Spells: </strong>{' '}
{data.spells.length ? data.spells.join(', ') : '-'}
</p>
<p>
<strong>Books: </strong>{' '}
{data.books.length ? data.books.join(', ') : '-'}
</p>
</div>
);
}
Jedna uwaga: ten komponent wywołuje fetchDataParalel() podczas renderowania, więc za każdym razem, gdy renderowana jest strona Home, tworzone jest nowe obietnienie. React oczekuje, że obietnice przekazywane do use będą stabilne, a w komponencie klienckim niezachowane obietnienie może powodować ostrzeżenia lub wielokrotne pobieranie danych. W Next.js bardziej idiomatyczną wersją jest utworzenie obietnicy w komponencie serwerowym (lub jej zazębienie) i przekazanie jej dalej, albo po prostu uczynienie tej sekcji asynchronicznym komponentem serwerowym otoczonym Suspense.
Promise.all: wszystko albo nic
Promise.all nadaje się do danych, które są w całości obowiązkowe. Rozwiązuje się on z tablicą wyników w oryginalnej kolejności, gdy tylko każdy element wejściowy zostanie spełniony, natomiast odrzuca żądanie zaraz po tym, jak którykolwiek z nich się nie powiedzie. Druga demonstracja wykorzystuje ten sam równoległy pobieranie danych, tym razem z funkcji useEffect, przy użyciu wyraźnego stanu loading zamiast mechanizmu Suspense:
'use client';
import React, { Suspense, use } from 'react';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function HomePromiseAll() {
const [loading, setLoading] = React.useState(false);
const [spells, setSpells] = React.useState([]);
const [books, setBooks] = React.useState([]);
const fetchDataParalel = async () => {
setLoading(true);
await delay(3000);
const getSpellsPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
).then((res) => res.json());
const getBooksPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
).then((res) => res.json());
const [spellsData, booksData] = await Promise.all([
getSpellsPromise,
getBooksPromise,
]);
setSpells(spellsData?.map((item: any) => item.spell) || []);
setBooks(booksData?.map((item: any) => item.title) || []);
setLoading(false);
};
React.useEffect(() => {
fetchDataParalel();
}, []);
return (
<main>
<h1>Demo Promise.all</h1>
<br />
{loading ? (
<p>Loading data...</p>
) : (
<Section1 spells={spells} books={books} />
)}
</main>
);
}
function Section1({ spells, books }: { spells: string[]; books: string[] }) {
return (
<div>
<p>
<strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
</p>
<p>
<strong>Books: </strong> {books.length ? books.join(', ') : '-'}
</p>
</div>
);
}
Zauważ, że jeśli któreś z żądań się nie powiedzie, wykonywanie kodu nigdy nie dotrze do instrukcji setLoading(false), w wyniku czego strona będzie wiecznie pokazywać komunikat „Loading data...”. Należy otoczyć cały kod w blokach try/finally oraz wyświetlić stan błędu w sekcji catch, aby odrzucenie żądania zostało uwidocznione, a nie zignorowane. Promise.all również nie anuluje pozostałych żądań w przypadku awarii jednego z nich; po prostu przestaje na nie czekać.
Promise.allSettled: tolerowanie częściowych niepowodzeń
Promise.allSettled nigdy nie odrzuca żadnej prośby. Czeka na wszystkie dane wejściowe i zwraca obiekt dla każdego z nich, zawierający status albo fulfilled (wraz z value) albo rejected (wraz z reason). Następnie decydujesz, co pokazać na podstawie każdego wyniku:
'use client';
import React from 'react';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export default function HomePromiseAllSettled() {
const [loading, setLoading] = React.useState(false);
const [spells, setSpells] = React.useState<string[]>([]);
const [books, setBooks] = React.useState<string[]>([]);
const fetchDataParalel = async () => {
setLoading(true);
await delay(3000);
const getSpellsPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/spells?max=3&page=1'
).then((res) => res.json());
const getBooksPromise = fetch(
'https://potterapi-fedeperin.vercel.app/en/books?max=3&page=1'
).then((res) => res.json());
const [spellsResult, booksResult] = await Promise.allSettled([
getSpellsPromise,
getBooksPromise,
]);
// check status 'fulfilled' and get from `.value`
const finalSpells =
spellsResult.status === 'fulfilled'
? spellsResult.value.map((item: any) => item.spell)
: [];
const finalBooks =
booksResult.status === 'fulfilled'
? booksResult.value.map((item: any) => item.title)
: [];
setSpells(finalSpells);
setBooks(finalBooks);
setLoading(false);
};
React.useEffect(() => {
fetchDataParalel();
}, []);
return (
<main>
<h1>Demo Promise.allSettled</h1>
<br />
{loading ? (
<p>Loading data...</p>
) : (
<Section1 spells={spells} books={books} />
)}
</main>
);
}
function Section1({ spells, books }: { spells: string[]; books: string[] }) {
return (
<div>
<p>
<strong>Spells: </strong> {spells.length ? spells.join(', ') : '-'}
</p>
<p>
<strong>Books: </strong> {books.length ? books.join(', ') : '-'}
</p>
</div>
);
}
Tutaj nieudana prośba o dane zwraca pustą listę, mimo że książki nadal są wyświetlane. To właściwa polityka dla treści pomocniczych, ale musisz wiedzieć, co uznaje się za niepowodzenie. fetch odrzuca dane tylko w przypadku błędów sieciowych, a wywołanie .json() odrzuca dane tylko wtedy, gdy treść jest niedozwolona do parsowania. Błąd HTTP 500, który zwraca JSON, nadal jest traktowany jako fulfilled, więc jeśli chcesz, aby błędy serwera były traktowane jako odrzucenia, sprawdź wartość res.ok w każdej kolejności operacji.
Wybór odpowiedniej kombinacji
- Użyj
Promise.all, gdy widok nie ma sensu bez każdego elementu danych, i połącz go z granicą błędów lub wyraźnym stanem błędu. - Użyj
Promise.allSettled, gdy niektóre sekcje są opcjonalne i częściowa strona jest lepsza niż żadna. - Otocz każdą sekcję ładowaną niezależnie własną granicą
Suspense, aby powolne dane blokowały tylko swoją obszar. - Zachowaj stabilność obietnic: utwórz je na serwerze lub zapisz w pamięci cache, a nie przy każdym renderowaniu na stronie klienta.
Podsumowanie
Kaskady i pojedyncze punkty awarii to wybory architektoniczne, a nie nieuchronności. Rozpoczynanie niezależnych zadań razem, świadome wybieranie polityki radzenia sobie z awariami oraz pozwalanie Suspense na zarządzanie obszarami ładowania zapewniają użytkownikom stronę, która pojawia się szybko i stopniowo traci funkcjonalność w przypadku problemów z jedną z zależności.
Literatura pokrewna
- Projektowanie SaaS do rozmów z AI przy użyciu Next.js, FastAPI, Credits i SSE — Przejrzenie architektury rozmów z AI typu full-stack z bramką FastAPI, rejestrem kredytów, ograniczeniami szybkości w Redis oraz strumieniowaniem SSE, a także identyfikacja najważniejszych braków do uzupełnienia.
- Prawidłowe wyznaczanie granicy ‘use client’ w Next.js App Router — Dowiedz się, co faktycznie oznacza dyrektywa ‘use client’, dlaczego oznaczanie kontenerów powoduje zwiększenie rozmiaru plików, oraz w jaki sposób komponenty liściowe i pola dziecięce utrzymują obliczenia serwera na serwerze.