Загрузка, пустой список и ошибки
Первый GET связывает компонент с локальным API, но асинхронному интерфейсу нужны понятные промежуточные состояния. Пользователь не должен угадывать, исчезли ли карточки, продолжается ли загрузка или сервер недоступен. Разберём три состояния запроса и добавим повторное чтение, которое запускается только по явному действию.
Учебные данные остаются прежними. Мы не вставляем искусственную задержку в сервер и не объявляем измеренную скорость. В реальной самостоятельной работе быстрый ответ может почти сразу сменить загрузочную подпись, а при медленной сети она будет заметнее. Логика должна оставаться правильной в обоих случаях.
Одно состояние вместо противоречащих флагов
Можно создать isLoading, hasError и notes отдельными значениями. Но тогда легко забыть сбросить один флаг: на странице одновременно появятся ошибка и новые карточки. Наш объект state хранит status, data и error вместе. При переходе к loading данные и ошибка сбрасываются одной операцией.
Успешный ответ переводит состояние в success с проверенным массивом. Ошибка переводит его в error и убирает data. Мы сознательно не сохраняем старые карточки во время обновления. Показ старого содержимого с отдельной отметкой обновления тоже возможен, но потребовал бы другого, явно описанного контракта интерфейса.
Полный файл src/pages/Home.jsx для этого урока:
import {useEffect, useState} from 'react';
import LessonList from '../components/LessonList.jsx';
import {requestJson} from '../lib/api.js';
import {parseNotes} from '../lib/models.js';
export default function Home() {
const [state, setState] = useState({status: 'loading', data: null, error: null});
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let active = true;
const controller = new AbortController();
setState({status: 'loading', data: null, error: null});
async function load() {
try {
const response = await requestJson('/api/notes', {signal: controller.signal});
const data = parseNotes(response.data);
if (active) setState({status: 'success', data, error: null});
} catch (error) {
if (active && !controller.signal.aborted) setState({status: 'error', data: null, error});
}
}
load();
return () => {active = false; controller.abort();};
}, [attempt]);
return <section id="catalog"><h1>Учебный каталог</h1>
{state.status === 'loading' && <p role="status">Загружаем карточки…</p>}
{state.status === 'error' && <div role="alert" className="error"><p>Не удалось получить каталог.</p><button type="button" onClick={() => setAttempt(value => value + 1)}>Повторить чтение</button></div>}
{state.status === 'success' && <LessonList title="Все карточки" notes={state.data} />}
</section>;
}
В JSX каждый блок связан с конкретным status. Сообщение загрузки имеет role="status", ошибка — role="alert". Это помогает вспомогательным технологиям заметить изменение, но само по себе не заменяет проверку доступности всего приложения. Например, порядок фокуса и поведение при переходе на другую страницу мы будем рассматривать отдельно от текста запроса.
Пустой результат тоже бывает успешным
Если сервер вернул пустой массив, parseNotes принимает его как корректный набор. Status становится success, а LessonList выводит сообщение о пустой подборке. Нельзя определять успех по data.length: ноль записей не равен сетевой ошибке. Нельзя также использовать data || сообщение, потому что пустой массив в JavaScript является истинным значением.
| Ситуация | Состояние | Что видит читатель |
|---|---|---|
| Запрос ещё ожидается | loading | Загружаем карточки |
| 200 и три корректные записи | success | Три карточки |
| 200 и пустой массив | success | Пустая подборка |
| 404 по адресу API | error | Ошибка и повторное чтение |
| 200 с неправильной моделью | error | Ошибка, без странных карточек |
Последняя строка показывает ценность проверки схемы. Допустим, minutes стал строкой "12" вместо числа. JSON синтаксически правильный, но parseNotes отклонит запись. Преобразовывать любые пришедшие значения автоматически опасно: оно может скрыть изменение серверного контракта и сделать ошибочные данные привычными.
Где появляется ошибка запроса
Fetch возвращает Response и для многих ошибочных HTTP-статусов. Поэтому requestJson отдельно проверяет response.ok. Если эту проверку убрать, можно разобрать объект {code: "not_found"} как будто это каталог, а затем получить непонятную ошибку map. Причина будет далеко от места, где интерфейс упал.
После статуса проверяем тип содержимого, затем разбираем JSON и модель. Получается последовательность независимых границ. Ошибка сети возникает до доступного ответа; ошибочный статус относится к HTTP; повреждённый JSON — к формату; неподходящие поля — к модели. Пользователь получает краткое сообщение, а разработчик может исследовать конкретную границу через инструменты браузера.
В нашем UI не печатается произвольное error.message сервера. Текст «Не удалось получить каталог» понятен пользователю и не зависит от внутреннего исключения. Для полноценного приложения можно добавить безопасный код обращения и журналирование, но учебный пример не содержит готовой системы наблюдаемости.
Повторяем чтение по кнопке
Состояние attempt служит счётчиком попыток. Нажатие увеличивает его функциональным setter, а эффект зависит от attempt. Новая настройка сначала переводит интерфейс в loading и сбрасывает прежнюю ошибку. После этого начинается новый GET с новым контроллером отмены.
Мы не вызываем функцию загрузки прямо из JSX. Вызов onClick передаёт обработчик, который меняет attempt только по событию. Если написать setAttempt(...) непосредственно в атрибуте, компонент начнёт обновляться при рендере. Это тот же тип ошибки, который мы уже рассматривали на callback скрытия.
Кнопка существует только при error. Когда новая попытка перешла в loading, старая ошибка вместе с кнопкой исчезает. Мы не запускаем бесконечный автоматический цикл до успеха: причина может быть устойчивой, например неправильный адрес прокси. Повторный GET пригоден после исправления причины, но сам по себе её не устраняет.
Очистка не должна подменять результат
Перед уходом со страницы очистка выставляет active=false и вызывает controller.abort. В catch есть проверка обоих условий, поэтому намеренная отмена не превращается в пользовательскую ошибку. Старый запрос не получает возможности поставить error поверх результата нового запроса.
Неправильный вариант — безусловный finally с setLoading(false) из любого завершившегося запроса. Если старая работа завершится после начала новой, её finally может убрать новую загрузочную подпись. Наш объект состояния и проверка active привязывают обновление к конкретному запуску эффекта.
Это применение правил настройки и очистки из документации useEffect. Правила библиотеки определяют жизненный цикл; конкретные статусы, сообщения и политика очистки данных принадлежат нашему приложению. Их нельзя выдать за единственный разрешённый способ построения загрузки.
Как отличать причину от симптома
Если карточки не появляются, сначала смотрим, остался ли status loading или произошёл error. Затем сопоставляем запрос /api/notes с ожидаемым адресом и статусом. Ответ HTML указывает на неверную маршрутизацию; объект вместо массива — на несогласованный endpoint; отсутствие ответа — на недоступность процесса или соединения.
Не нужно добавлять начальные карточки в catch ради красивого экрана. Пользователь решит, что получил актуальные данные, хотя запрос не удался. Для режима offline понадобится отдельная политика происхождения и давности содержимого. В этом курсе такой режим не реализован.
После самостоятельного запуска успешное чтение должно убрать загрузочный текст и показать каталог. При недоступном API ожидается ошибка, а после восстановления процесса кнопка разрешает новую попытку. Эти сценарии описывают ожидаемую ручную сверку исходника, а не проведённые испытания. Далее вынесем повторяющуюся асинхронную логику в собственный хук и подготовим её к загрузке карточки по отдельному URL.