Паралельне отримання даних у React: Suspense з Promise.all та allSettled
Дізнайтеся, як видаляти послідовності запитів за допомогою Promise.all, як уберегти необов’язкові дані від пошкодження сторінок за допомогою Promise.allSettled та як відображати стани завантаження за допомогою Suspense.
Сторінка, на якій постійно відображаються індикатори завантаження, зазвичай має одну з двох проблем. Або її запити виконуються по одному, хоча могли б виконуватися одночасно, або якийсь необов’язковий запит, наприклад віджет з рекомендаціями, зазнає невдачі та призводить до зупинки всього відображення. Існують три інструменти, які вирішують обидві проблеми: Promise.all та Promise.allSettled для одночасного виконання завдань із різними стратегіями обробки помилок, а також Suspense у React для відображення запасного варіанту поки дані ще не отримані. У цьому посібнику розглядається кожен з цих інструментів за допомогою невеликої демонстрації, яка завантажує два списки з публічного API.
Звідки походять «водоспади»
Очікування на виконання запитів по одному призводить до їх серіалізації. Другий запит навіть не починається, поки перший не завершиться, тому загальна затримка дорівнює сумі обох:
const categories = await fetchCategories();
// fetchPosts will only start running after fetchCategories finishes executing.
const posts = await fetchPosts();
Коли запити не залежать один від одного, спочатку запустіть їх обидва та дочекайтеся їх разом. Тоді загальний час скорочується приблизно до часу виконання найповільнішого запиту, а не до суми часів усіх запитів. Для більш детального порівняння цих інструментів дивіться вибір між Promise.all, Promise.race та послідовними awaits.
Suspense: декларативні стани завантаження
Suspense обгортає частину структури та відображає fallback, такий як скелет або повідомлення „Завантаження даних...“, поки все, що знаходиться всередині, очікує. Решта сторінки залишається видимою, і вам більше не потрібен вручну створений флаг loading для цієї частини.
У демонстрації асинхронна функція чекає три секунди, щоб стан завантаження став видимим, потім одночасно виконує запити на використання магічних заклинань та книг, а потім об’єднує їх за допомогою Promise.all. Отриманий обіцянок передається до Section1, який розгортає його за допомогою гука use у React. Доки обіцянок не буде виконано, use призупиняє роботу компонента, і відображається запасний варіант.
'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>
);
}
Одна застереження: цей компонент викликає fetchDataParalel() під час відображення, тож щоразу, коли відображається Home, створюється нова обіцянка. React очікує, що обіцянки, передані до use, будуть стабільними, і в клієнтському компоненті некешована обіцянка може спричинити попередження чи багаторазове отримання даних. У Next.js більш ідіоматичною версією є створення обіцянки у серверному компоненті (або її кешування) та передача її далі, або просто перетворення цього розділу на асинхронний серверний компонент, обгорнутий у Suspense.
Promise.all: все або нічого
Promise.all підходить для даних, усі елементи яких є обов’язковими. Він завершується масивом результатів у початковому порядку, як тільки всі вхідні елементи будуть виконані, а також відхиляється як тільки будь-який з них відхилиться. У другому демонстраційному прикладі використовується той самий паралельний запит, але цього разу з функції useEffect з явним станом loading замість 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>
);
}
Зверніть увагу, що якщо будь-який запит відхилиться, виконання ніколи не досягне рядка setLoading(false), і сторінка буде вічно показувати „Завантаження даних...“. Обгорніть основний код у блоки try/finally та відображайте стан помилки у блоку catch, щоб відхилення було видимим, а не прихованим. Promise.all також не скасовує інший запит у разі його невдачі; він просто припиняє на нього чекати.
Promise.allSettled: толерування часткових невдач
Promise.allSettled ніколи не відхиляється. Він чекає на кожен вхідний елемент та повертає об’єкт для кожного з них із status, який може бути або fulfilled (зі значенням value) або rejected (з причиною reason). Потім ви самостійно вирішуєте, що показати на основі кожного результату:
'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>
);
}
У цьому випадку невдала заявка на отримання даних призводить до порожнього списку, тоді як книги все одно відображаються. Це правильна політика для додаткового контенту, але слід усвідомлювати, що вважається невдачею. fetch відхиляється лише у разі мережевих помилок, а виклик .json() — лише у разі непарсованих даних. HTTP-статус 500, який повертає JSON, все одно вважається fulfilled, тому якщо ви хочете, щоб помилки сервера оброблялися як відхилення, перевіряйте res.ok у кожному етапі обробки.
Вибір правильної комбінації
- Використовуйте
Promise.all, коли відображення є беззмістовним без кожної частини даних, та поєднуйте його з межею обробки помилок або явним станом помилки. - Використовуйте
Promise.allSettled, коли деякі розділи є необов’язковими, і часткова сторінка краща, ніж її відсутність. - Обгорніть кожен окремо завантажуваний розділ у власну межу
Suspense, щоб повільні дані впливали лише на свою область. - Зберігайте обіцянки стабільними: створюйте їх на сервері або кешуйте, а не під час кожного відображення на клієнті.
Підсумок
Структура типу «водоспад» та єдині точки відмови є архітектурними рішеннями, а не неминучістю. Початок окремої роботи одночасно, свідомий вибір політики обробки помилок та використання Suspense для керування областями завантаження дозволяють створити сторінку, яка швидко відображається та грайно працює навіть у разі несправності однієї з залежностей.