import { useState, useEffect, useRef } from 'react';
// Уязвимый компонент с утечкой памяти
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => {
// Что если компонент размонтировался до завершения запроса?
setUser(data);
});
}, [userId]);
if (!user) return <p>Загрузка...</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
Приведенный компонент выглядит непритязательно, но содержит серьезные риски для приложения. Представьте ситуацию: пользователь переключается между профилями со скоростью администратора, у которого горят сроки. Пока грузится профиль №1, пользователь уже перешёл на №2, а затем на №3. Опасность в том, что старые запросы продолжают выполняться и при попытке обновить состояние в размонтированном компоненте вызывают ошибку. Браузер жалуется: "Можно выполнить только обновление установленного компонента", а приложение теряет предсказуемость.
Математика непредсказуемости
Проблему можно выразить через формальное условие:
Событие завершения запроса ∩ Состояние смонтированного компонента = ∅
Когда это условие нарушается — происходит сбой. С технической стороны:
- Асинхронная операция стартует при монтировании
- Компонент размонтируется раньше получения результата
- Неотменённый запрос завершается и пытается сохранить данные
- Обновление состояния несуществующего компонента → ошибка состояния
Практическое решение: Подключаем AbortController
function SafeUserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// В контексте хука создаём контроллер
const abortController = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (err) {
// Игнорируем ошибку при отмене запроса
if (err.name !== 'AbortError') {
setError(err.message);
}
}
};
fetchData();
// Прерывание запущенных операций при размонтировании
return () => abortController.abort();
}, [userId]); // Повтор при смене userId
if (error) return <p>Ошибка: {error}</p>;
if (!user) return <p>Загрузка профиля...</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}
Почему это работает
Интерфейс AbortController
стал стандартом в современном JavaScript. Под капотом:
abortController.signal
распространяет команду отмены через приложенное к нему promise- Вызов
abort()
меняет статус сигнала на "прерван" - Fetch API автоматически отклоняет промис с
AbortError
- Обработчик ошибок корректно фильтрует эту ситуацию
В области зависимостей useEffect остаются userId
и url
— при их изменении эффект выполнит повтор, предварительно отменив предыдущий запрос.
Универсализация подхода: кастомный хук
function useAbortableFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const abortController = new AbortController();
const execute = async () => {
try {
setLoading(true);
const response = await fetch(url, {
signal: abortController.signal,
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`);
}
const result = await response.json();
setData(result);
setError(null);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
execute();
return () => abortController.abort();
}, [url]); // Зависимость от URL
return { data, error, loading };
}
// Использование в компоненте
function ProductDetails({ productId }) {
const {
data: product,
error,
loading
} = useAbortableFetch(`/api/products/${productId}`);
if (error) return <ErrorCard message={error} />;
if (loading) return <SkeletonCard />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<PriceTag value={product.price} />
</div>
);
}
Гонка состояний: дополнительный уровень защиты
Даже при наличии отмены, возможна ситуация "первым приходит последний отправленный запрос".
useEffect(() => {
const abortController = new AbortController();
let isActive = true;
const fetchData = async () => {
const result = await fetch(/* ... */);
if (isActive) {
setData(result);
}
};
fetchData();
return () => {
abortController.abort();
isActive = false;
};
}, [dependency]);
Мы добавляем булев флаг isActive
, который синхронизирован с жизненным циклом компонента. Комбинация двух подходов гарантирует:
- Физическое прерывание запроса через сетевые механизмы
- Логическое игнорирование устаревших результатов
Экспериментальный подход: React Forget
Перспективное решение от команды React — компилятор React Forget автоматически добавляет отмену в асинхронные операции без ручной реализации. Но до его релиза сохраняются актуальность наших практик.
Архитектурные соображения
- Уровень API: Инкапсулируйте работу с сетевыми запросами
- Кеширование: Интегрируйте React Query или SWR для глобального управления состоянием
- Обработка ошибок: Унифицируйте стратегии для сетевых сбоев
- Время жизни запросов: Устанавливайте таймауты через
setTimeout
+AbortController
const fetchWithTimeout = (url, timeout = 8000) => {
const abortController = new AbortController();
const timerId = setTimeout(() => {
abortController.abort();
}, timeout);
const request = fetch(url, {
signal: abortController.signal
}).finally(() => clearTimeout(timerId));
return request;
};
Почему стоит соблюдать дисциплину
Необходимость в явном управлении асинхронными операциями вытекает из принципов:
- Стабильность компонентов: Компонент не должен нарушать работу приложения после размонтирования
- Детерминизм рендеринга: Состояние интерфейса должно однозначно соответствовать данным
- Реализм ошибок: Нельзя игнорировать неизбежные сетевые сбои
- Оптимизация ресурсов: Каждый прерванный запрос высвобождает критически важные соединения
Соответствие этим принципам отличает зрелую инженерную реализацию от экспромта в сжатые сроки.
Дисциплина управления асинхронными операциями в React не относится к сложным разделам фреймворка, но ее пренебрежение приводит к катастрофическим последствиям. Внедрение паттерна AbortController
в стандартный процесс разработки устраняет целые классы багов, укрепляя надежность приложения. Когда мы программируем по принципам изоморфной реактивности, где контекст выполнения первичен, управление жизненным циклом становится таким же обыденным навыком, как работа с промисами.