Параллельный захват данных в 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 для управления зонами загрузки позволяют создавать страницы, которые быстро открываются и плавно переходят в режим сниженной производительности при сбое какого-либо компонента.