Параллельный захват данных в React: Suspense с Promise.all и allSettled
Узнайте, как удалять последовательные запросы с помощью Promise.all, защищать необязательные данные от сбоев на страницах с помощью Promise.allSettled и отображать состояния загрузки с использованием Suspense.
Источники: Mastering Parallel Data Fetching in Next.js: Combining Suspense, Promise.all, and Promise.allSettled
Страница, на которой постоянно отображаются индикаторы загрузки, обычно имеет одну из двух проблем. Либо её запросы выполняются по одному, хотя могли бы идти одновременно, либо какой-то необязательный запрос, например всплывающее окно с рекомендациями, терпит неудачу и приводит к сбою всего интерфейса. Три инструмента помогают решить обе проблемы: 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 для управления зонами загрузки позволяют создавать страницы, которые быстро открываются и плавно переходят в режим сниженной производительности при сбое какого-либо компонента.