Перейти к содержимому

Шаг 3. Загрузка данных (useEffect)

Пора подключить «сервер». Задачи будут загружаться из taskApi.getTasks() (см. Окружение) — с задержкой и шансом ошибки. Это первый шаг с асинхронностью, и здесь много подводных камней: состояния загрузки, ошибки, очистка и гонки запросов.

  • При монтировании компонента загрузи задачи через taskApi.getTasks(signal);
  • заведи состояние-машину: idleloadingsuccess (с данными) / error (с сообщением);
  • покажи спиннер при loading, сообщение и кнопку «Повторить» при error, список при success;
  • добавь кнопку «Перезагрузить», которая запускает загрузку заново;
  • защити от гонок: если запущен новый запрос (или компонент размонтирован), результат старого не должен перезаписать состояние;
  • очисти эффект корректно (cleanup-функция).
  • 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.