Сохранённые статьи уже пришли, а рекомендации завершились ошибкой. Страница может показать полезную часть сразу и предложить повторить только сбойный запрос. Соберём такой экран и проверим, какую работу здесь выполняет Promise.allSettled.

Ошибка одного запроса не стирает другой результатОшибка одного запроса не стирает другой результатСохранённые статьиfulfilledЗапрос рекомендацийrejectedСтатьи остаются доступныВыводим успешный результатОтдельное сообщениеПовторяем только этот запросallSettled ждёт завершения всех переданных промисов.
Promise.allSettled даёт результат каждого промиса после завершения всех. Обработчик может сохранить успешные данные и отдельно показать отказ другого блока.

Разделите страницу на самостоятельные состояния

У сохранённых статей и рекомендаций разные источники данных. Человек может читать сохранённое, пока рекомендации загружаются. Поэтому для каждого раздела заведём собственные состояния: загрузка, данные, пустой результат и ошибка. Общая надпись нужна лишь для отчёта о первой загрузке.

Это решение подходит независимым данным. Если цена заказа рассчитывается по результату другого запроса, их связь придётся описать отдельно. Показывать цену из старой корзины рядом с новым составом заказа опасно: визуально готовый блок ещё не означает согласованный результат.

Для примера понадобятся базовые знания промисов, модулей JavaScript и DOM, локальный Node.js и браузер. Зависимостей нет. В отдельной папке создайте index.html, api.mjs, app.js, server.mjs и check.mjs. Сервер раздаёт учебные файлы только на вашем компьютере; для рабочего сайта его использовать не следует.

В api.mjs будет управляемая имитация двух API. Сохранённое возвращается через 200 мс. Рекомендации ждут 1600 мс и падают при первом вызове. Это специально выбранные задержки, чтобы увидеть порядок событий, без обращения к настоящим сервисам.

Подготовьте два блока с отдельными кнопками

Сохраните разметку в index.html:

<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Независимые блоки</title>
<style>
body { font: 1.1rem/1.5 system-ui; max-width: 55rem; margin: 2rem auto; padding: 1rem; }
main { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); }
section { border: 1px solid #777; padding: 1rem; }
button { font: inherit; }
button:focus-visible { outline: 3px solid #164bbb; outline-offset: 3px; }
</style>
<h1>Личный кабинет читателя</h1>
<p id="summary" role="status"></p>
<main>
  <section id="saved" aria-labelledby="saved-title">
    <h2 id="saved-title">Сохранённые статьи</h2>
    <p data-status role="status"></p>
    <ul data-items></ul>
    <button type="button" data-retry>Загрузить сохранённые</button>
  </section>
  <section id="suggested" aria-labelledby="suggested-title">
    <h2 id="suggested-title">Рекомендации</h2>
    <p data-status role="status"></p>
    <ul data-items></ul>
    <button type="button" data-retry>Загрузить рекомендации</button>
  </section>
</main>
<script type="module" src="app.js"></script>
</html>

Кнопки существуют с момента открытия страницы, поэтому обработчики можно назначить один раз. Скрипт меняет текст статуса и элементы списка, сохраняя саму кнопку. Проверьте повтор с клавиатуры: перейдите к кнопке клавишей Tab и нажмите Enter.

role="status" задаёт область для спокойных сообщений об изменениях. Сами сообщения короткие: экрану не требуется повторно озвучивать весь список при каждом обновлении. aria-busy будем переключать на конкретном разделе. Соседний список продолжает работать, даже когда этот раздел ещё ожидает ответ источника.

Заголовки связываются с разделами через aria-labelledby. Ошибку покажем словами. Красная рамка могла бы усилить оформление, однако без текста человек не поймёт, что произошло и какое действие доступно.

Сделайте задержку и ошибку воспроизводимыми

Создайте api.mjs:

const attempts = new Map();
const data = {
  saved: ['Формы без ловушек', 'Таблица на узком экране'],
  suggested: ['Фильтры в адресе страницы', 'Поиск без старых ответов'],
};

export function requestBlock(key) {
  const attempt = (attempts.get(key) ?? 0) + 1;
  attempts.set(key, attempt);
  return new Promise((resolve, reject) => {
    const delay = key === 'saved' ? 200 : 1600;
    setTimeout(() => {
      if (key === 'suggested' && attempt === 1) {
        reject(new Error('Учебный сбой рекомендаций'));
        return;
      }
      resolve(data[key] ?? []);
    }, delay);
  });
}

Модуль считает попытки отдельно для каждого ключа. Первый вызов рекомендаций отклоняет промис, второй возвращает данные. После перезагрузки счётчик обнуляется. Так можно повторять один сценарий без ручного переключения сети.

Функция здесь заменяет транспортный слой. В рабочем приложении на её месте окажется запрос, который возвращает проверенные данные либо отклоняется с ошибкой. Если используете Fetch, отдельно проверяйте HTTP-статус: ответ с 500 сам по себе не превращает промис fetch в отклонённый. Этот шаг подробно разобран в статье «Ошибки Fetch, загрузка и повтор запроса».

Нас интересует следующий слой: как сохранить полезную часть интерфейса, когда один источник уже дал результат, а другой ещё работает или завершился сбоем.

Отрисуйте каждый результат до общего отчёта

Запишите app.js:

import { requestBlock } from './api.mjs';

const summary = document.querySelector('#summary');
const blocks = ['saved', 'suggested'].map((key) => {
  const root = document.getElementById(key);
  return {
    key, root,
    status: root.querySelector('[data-status]'),
    list: root.querySelector('[data-items]'),
    retry: root.querySelector('[data-retry]'),
    pending: false,
  };
});

function renderItems(block, items) {
  block.list.replaceChildren(...items.map((title) => {
    const item = document.createElement('li');
    item.textContent = title;
    return item;
  }));
}

async function loadBlock(block) {
  if (block.pending) return;
  block.pending = true;
  block.retry.disabled = true;
  block.root.setAttribute('aria-busy', 'true');
  block.status.textContent = 'Загружаем…';
  try {
    const items = await requestBlock(block.key);
    renderItems(block, items);
    block.status.textContent = items.length ? 'Готово.' : 'Пока пусто.';
    return items;
  } catch (error) {
    block.status.textContent = 'Загрузка не удалась. Можно повторить ниже.';
    throw error;
  } finally {
    block.pending = false;
    block.retry.disabled = false;
    block.root.setAttribute('aria-busy', 'false');
  }
}

for (const block of blocks) {
  block.retry.addEventListener('click', async () => {
    try {
      await loadBlock(block);
    } catch (error) {
      console.info('Блок всё ещё недоступен:', block.key, error.message);
    }
  });
}

async function loadPage() {
  summary.textContent = 'Загружаем разделы независимо друг от друга.';
  const results = await Promise.allSettled(blocks.map(loadBlock));
  const failed = results.filter((result) => result.status === 'rejected').length;
  summary.textContent = failed
    ? `Первая загрузка завершена. Недоступных разделов: ${failed}.`
    : 'Все разделы первой загрузки готовы.';
}
loadPage();

При вызове blocks.map(loadBlock) обе асинхронные функции начинают работу. Каждая дойдёт до своего await requestBlock(...) и вернёт промис. После ответа первая функция обновит свой раздел. Она делает это внутри loadBlock, поэтому ей не нужно ждать завершения соседнего запроса.

Затем Promise.allSettled собирает итоговые состояния первой группы. В массиве результатов порядок соответствует входным промисам: первый элемент относится к saved, второй к suggested. Более ранний ответ не переставляет элементы. Для успешного результата доступно value, для отклонённого — reason; обращаться к ним следует после проверки status.

В этом интерфейсе общий отчёт появляется после завершения самого медленного раздела. Так и задумано: надпись сообщает, что первая попытка закончена. Списки и локальные ошибки появляются раньше. Если бы вся отрисовка стояла после общего await, сохранённые статьи ждали бы рекомендации зря.

Обратите внимание на throw error в локальном catch. Сначала функция показывает ошибку пользователю, затем сохраняет отклонённое состояние промиса для общей сводки. Если убрать повторный выброс, асинхронная функция завершится успешно со значением undefined. Тогда итоговый отчёт назовёт сбойный раздел успешным.

У кнопки повтора есть свой try/catch, потому что её вызов уже не входит в первоначальный allSettled. Ошибка повтора остаётся локальной и не становится необработанным отклонением. В консоли остаётся диагностическое сообщение; пользователю показываем действие, а не внутренний стек ошибки.

Флаг pending защищает от двух одновременных загрузок одного блока. Кнопку блокируем, чтобы показать ожидание пользователю. Сама функция проверяет pending перед запуском операции. Повтор обновляет только свой список. Данные другого раздела мы даже не читаем.

Общая сводка намеренно называется отчётом о первой загрузке. После локального повтора она остаётся историческим сообщением, а актуальный статус находится внутри раздела. Если нужен постоянно актуальный счётчик ошибок, храните состояния блоков отдельно и пересчитывайте счётчик при каждом переходе состояния.

Запустите страницу и поймайте момент готовности

Создайте server.mjs:

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const files = new Map([
  ['/', ['index.html', 'text/html; charset=utf-8']],
  ['/api.mjs', ['api.mjs', 'text/javascript; charset=utf-8']],
  ['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
]);
const server = createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const file = files.get(url.pathname);
  if (!file) { res.writeHead(404).end('Not found'); return; }
  try {
    const body = await readFile(new URL(file[0], import.meta.url));
    res.writeHead(200, { 'Content-Type': file[1] });
    res.end(body);
  } catch {
    res.writeHead(500).end('Cannot read file');
  }
});
server.listen(4173, '127.0.0.1', () => {
  console.log('Open http://127.0.0.1:4173');
});

Из папки примера выполните:

node server.mjs

Откройте http://127.0.0.1:4173. Сначала оба блока показывают загрузку. Затем появятся две сохранённые статьи, а рекомендации останутся в ожидании. Позже справа появится сообщение об ошибке. Нажмите «Загрузить рекомендации»: после следующей задержки справа тоже будут две статьи.

При проверке следите за левым списком. Он должен оставаться на месте и во время правой ошибки, и во время повтора. Это полезнее одного общего признака «страница загрузилась»: вы проверяете обещанную независимость данных.

Если порт занят, остановите прежний учебный сервер сочетанием Ctrl+C или выберите другой порт в server.mjs. Все адреса тогда открывайте с новым портом.

Воспроизведите вариант, который задерживает готовый раздел

На отдельной копии app.js можно проверить такую организацию кода:

const results = await Promise.allSettled([
  requestBlock('saved'),
  requestBlock('suggested'),
]);
console.log('Только здесь начинаем показывать оба раздела', results);

Данные сохранённого готовы раньше, однако код после await получит управление лишь после завершения рекомендаций. allSettled сохраняет информацию об обеих попытках, но сам по себе ничего не рисует и не доставляет частичные результаты в DOM.

Верните полный app.js. Позиция обновления интерфейса определяет, когда человек увидит данные. Комбинатор промисов определяет, как вы получите общий итог. Эти решения удобно принимать отдельно.

Другой полезный сбой уже находится в локальном catch: временно уберите throw error. После перезагрузки правый блок покажет ошибку, а общая строка объявит готовность всех разделов. Несогласованность видна без отладчика. Верните строку и убедитесь, что число недоступных разделов снова равно одному.

Сохраните таблицу для диагностики

Наблюдение Что проверить Ожидаемый результат
Быстрый список ждёт соседний запрос Где вызывается функция отрисовки Внутри отдельной операции, до общей сводки
Локальная ошибка есть, общий итог успешный Возвращает ли catch отклонение После сообщения выполняется throw error
Повтор обнуляет соседний список Какие DOM-узлы меняет обработчик Только узлы выбранного блока
После повтора ошибка в консоли как необработанная Кто принимает отклонение нового вызова У обработчика кнопки есть try/catch
Пустой массив выглядит как вечная загрузка Проверка items.length Отдельное сообщение «Пока пусто»
Двойной клик создаёт два запроса Флаг перед началом операции Второй вызов завершится до транспорта
Общее сообщение выглядит устаревшим О какой попытке оно говорит Явно названа первая загрузка либо сводка пересчитывается по актуальным состояниям разделов

Ошибку загрузки и пустую выдачу оставляйте разными состояниями. При пустом массиве источник ответил корректно. При ошибке мы не знаем, есть ли у него статьи. Слово «пусто» в такой ситуации вводит читателя в заблуждение.

Есть и ограничение: если один источник никогда не завершает операцию, общий отчёт о первой попытке так и останется в ожидании. Сам allSettled не задаёт срок ожидания и не отменяет работу. Для настоящего транспорта добавляют согласованную политику тайм-аута и отмены, а локальный статус продолжают показывать независимо. Готовые разделы всё равно должны быть видны.

Проверьте сценарии и добавьте свой блок

Сохраните check.mjs и запустите node check.mjs в новом терминале:

import assert from 'node:assert/strict';
import { requestBlock } from './api.mjs';
const order = [];
const saved = requestBlock('saved').then((items) => { order.push('saved'); return items; });
const suggested = requestBlock('suggested');
const results = await Promise.allSettled([saved, suggested]);
assert.deepEqual(order, ['saved']);
assert.equal(results[0].status, 'fulfilled');
assert.equal(results[1].status, 'rejected');
assert.equal((await requestBlock('suggested')).length, 2);
assert.deepEqual(await requestBlock('unknown'), []);
console.log('PASS: success, independent failure, retry, empty');

Проверка выполняет настоящий код учебного источника и подтверждает успешный ответ, управляемый сбой, удачный повтор и пустой массив. Доступность и момент появления DOM она не проверяет. Для этого оставьте страницу открытой и пройдите сценарий с помощью клавиши Tab.

Сначала измените массив suggested на пустой, повторите загрузку и убедитесь, что появился статус «Пока пусто». Затем самостоятельно добавьте раздел «История чтения» с отдельной кнопкой и задержкой. Ошибка этого раздела должна оставлять оба прежних списка доступными. Для нового ключа потребуется добавить данные и сценарий сбоя в api.mjs, разметку и имя в массиве blocks.

Когда захотите собрать больше подобных взаимодействий, посмотрите курс JavaScript в HTML Academy. А в этом примере оставьте себе конкретный критерий: любой готовый независимый блок доступен, пока соседний ещё ждёт ответа.