Шаг 3. Загрузка данных (useEffect)
Пора подключить «сервер». Задачи будут загружаться из taskApi.getTasks() (см. Окружение) — с задержкой и шансом ошибки. Это первый шаг с асинхронностью, и здесь много подводных камней: состояния загрузки, ошибки, очистка и гонки запросов.
Постановка задачи
Заголовок раздела «Постановка задачи»- При монтировании компонента загрузи задачи через
taskApi.getTasks(signal); - заведи состояние-машину:
idle→loading→success(с данными) /error(с сообщением); - покажи спиннер при
loading, сообщение и кнопку «Повторить» приerror, список приsuccess; - добавь кнопку «Перезагрузить», которая запускает загрузку заново;
- защити от гонок: если запущен новый запрос (или компонент размонтирован), результат старого не должен перезаписать состояние;
- очисти эффект корректно (cleanup-функция).
Какие API и зачем
Заголовок раздела «Какие API и зачем»useEffect(() => {...}, [deps])— загрузка данных это побочный эффект, его место вuseEffect. Пустой массив зависимостей — загрузка один раз при монтировании.- Cleanup-функция (
return () => ...) — вызывается при размонтировании и перед повторным запуском эффекта. Сюда — отмену запроса и сброс флага. - Защита от гонок — два способа:
ignore-флаг: локальная переменная, которую cleanup ставит вtrue; при получении ответа проверяем — если эффект устарел, не трогаем состояние.AbortController: передаёмsignalвtaskApi.getTasks(signal), в cleanup вызываемcontroller.abort(). Запрос реально отменяется, ошибкуAbortErrorигнорируем. В практикуме покажем оба и используем их вместе.
- Discriminated union для состояния — вместо трёх отдельных
useState(loading, error, data) один типобезопасный union: исключает невозможные комбинации (например, одновременноloadingиerror). useReducerили триггер-счётчик для перезагрузки — чтобы перезапустить эффект, меняем зависимость. Простой приём —reloadKey, число, которое инкрементим по кнопке.
Показать эталонную реализацию
import { useEffect, useState } from 'react';import type { Task } from '../types/task';import { taskApi } from '../api/taskApi';
// Состояние-машина: невозможные комбинации исключены типомtype LoadState = | { status: 'loading' } | { status: 'success'; tasks: Task[] } | { status: 'error'; message: string };
export function TaskLoader() { const [state, setState] = useState<LoadState>({ status: 'loading' }); const [reloadKey, setReloadKey] = useState(0);
useEffect(() => { let ignore = false; // защита 1: флаг устаревания const controller = new AbortController(); // защита 2: реальная отмена
setState({ status: 'loading' });
taskApi .getTasks(controller.signal) .then((tasks) => { if (!ignore) setState({ status: 'success', tasks }); }) .catch((err) => { if (ignore) return; if (err instanceof DOMException && err.name === 'AbortError') return; // отмену не показываем setState({ status: 'error', message: String(err.message ?? err) }); });
// cleanup: при размонтировании / перед новым запуском return () => { ignore = true; controller.abort(); }; }, [reloadKey]); // меняем reloadKey → эффект перезапускается
if (state.status === 'loading') return <p>Загрузка...</p>;
if (state.status === 'error') { return ( <div> <p>Ошибка: {state.message}</p> <button onClick={() => setReloadKey((k) => k + 1)}>Повторить</button> </div> ); }
return ( <div> <button onClick={() => setReloadKey((k) => k + 1)}>Перезагрузить</button> <ul> {state.tasks.map((task) => ( <li key={task.id}> <span style={{ textDecoration: task.done ? 'line-through' : 'none' }}> {task.title} </span> </li> ))} </ul> </div> );}Ловушки
Заголовок раздела «Ловушки»Дальше — Шаг 4: ссылки и оптимизация рендера: useRef, useCallback, React.memo.