Le chargement parallèle dans React : Suspense avec Promise.all et allSettled
Apprenez à supprimer les cascades de requêtes avec Promise.all, à éviter que des données optionnelles ne perturbent les pages avec Promise.allSettled, et à afficher des états de chargement avec Suspense.
Une page qui affiche des chargeurs les uns après les autres présente généralement l’un de deux problèmes. Soit ses requêtes s’exécutent une après l’autre alors qu’elles pourraient le faire en même temps, soit une requête optionnelle, comme un widget de recommandations, échoue et entraîne la panne de toute l’interface. Trois outils permettent de résoudre ces problèmes : Promise.all et Promise.allSettled pour lancer des tâches en parallèle avec différentes politiques de gestion des erreurs, ainsi que Suspense de React pour afficher un contenu de remplacement en attendant les données. Ce guide présente chacun d’eux à l’aide d’une petite démonstration chargant deux listes depuis une API publique.
D’où viennent les séquences en cascade
L’attente des appels un par un les sérialise. La deuxième requête ne commence même pas avant que la première ne soit terminée, de sorte que le temps total d’attente est la somme des deux :
const categories = await fetchCategories();
// fetchPosts will only start running after fetchCategories finishes executing.
const posts = await fetchPosts();
Lorsque les requêtes ne dépendent pas les unes des autres, lancez-les toutes deux en même temps et attendez leur exécution conjointement. Le temps total diminue alors à peu près au niveau de la requête la plus lente, plutôt que d’être égal à la somme des temps. Pour une comparaison plus approfondie de ces outils, consultez choosing between Promise.all, Promise.race and sequential awaits.
Suspense : états de chargement déclaratifs
Suspense enrobe une partie de l’arborescence et affiche un fallback, tel qu’un squelette ou un message « Chargement des données... », pendant que tout ce qui se trouve à l’intérieur attend. Le reste de la page reste visible, et vous n’avez plus besoin d’un indicateur loading manuellement défini pour cette section.
Dans la démonstration, une fonction asynchrone attend artificiellement trois secondes pour afficher l’état de chargement, puis lance simultanément les requêtes relatives aux sorts et aux livres et les combine à l’aide de Promise.all. La promesse résultante est transmise à Section1, qui la déroule à l’aide du hook use de React. Jusqu’à ce que la promesse soit résolue, use suspend le composant et l’affichage de secours s’affiche.
'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>
);
}
Un point d’attention : ce composant appelle fetchDataParalel() lors du rendu, ce qui crée une nouvelle promesse à chaque fois que Home est rendu. React attend que les promesses transmises à use soient stables, et dans un composant client, une promesse non mémorisée peut provoquer des avertissements ou des requêtes répétées. Dans Next.js, la version plus idiomatique crée la promesse dans un composant serveur (ou la mémorise) et la transmet, ou bien fait simplement de cette section un composant serveur asynchrone enveloppé dans Suspense.
Promise.all : tout ou rien
Promise.all convient aux données qui sont toutes obligatoires. Il résout avec un tableau de résultats dans l’ordre d’origine dès que chaque entrée est satisfaite, et il rejette la promesse dès que l’une d’elles échoue. La deuxième démonstration utilise la même requête en parallèle, cette fois depuis useEffect avec un état loading explicite plutôt que 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>
);
}
Remarquez que si l’une des requêtes échoue, l’exécution n’atteint jamais setLoading(false), et la page affiche "Chargement des données..." indéfiniment. Enveloppez le corps du code dans try/finally, et affichez un état d’erreur dans un catch, afin que l’échec soit mis en évidence plutôt que masqué. Promise.all ne cancelle pas non plus l’autre requête en cas d’échec ; il cesse simplement d’attendre sa réponse.
Promise.allSettled : tolérer des échecs partiels
Promise.allSettled ne rejette jamais. Il attend tous les éléments d’entrée et renvoie un objet pour chacun, contenant un status soit de type fulfilled (accompagné d’un value) soit de type rejected (accompagné d’un reason). Vous décidez ensuite, pour chaque résultat, ce qui doit être affiché :
'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>
);
}
Ici, une requête qui échoue génère une liste vide tandis que les livres continuent d’être affichés. C’est la politique appropriée pour le contenu secondaire, mais veillez à bien définir ce qui constitue un échec. fetch ne rejette que en cas d’erreur réseau, et l’appel .json() ne rejette que pour des corps de données impossibles à parser. Un HTTP 500 qui renvoie du JSON reste en état fulfilled ; par conséquent, vérifiez res.ok au sein de chaque chaîne si vous souhaitez que les erreurs serveur soient traitées comme des rejets.
Choisir la combinaison appropriée
- Utilisez
Promise.alllorsque la vue n’a aucun sens sans chacun des éléments de données, et associez-le à une limite d’erreur ou à un état d’erreur explicite. - Utilisez
Promise.allSettledlorsque certaines sections sont optionnelles et qu’une page partielle vaut mieux que rien. - Encadrez chaque section chargée de manière indépendante dans sa propre limite
Suspenseafin que des données lentes ne ralentissent qu’une région spécifique. - Gardez les promesses stables : créez-les sur le serveur ou mettez-les en cache, et non à chaque rendu côté client.
En résumé
Les architectures en cascade et les points de défaillance uniques sont des choix architecturaux plutôt que des inévitabilités. En lançant en même temps des tâches indépendantes, en choisissant délibérément une politique de gestion des erreurs, et en laissant Suspense gérer les zones de chargement, on offre aux utilisateurs une page qui s’affiche rapidement et se comporte de manière souple en cas de problème avec une dépendance.