Отмена HTTP-запросов: AbortController, Race Conditions и cleanup
Отмена запросов — обязательный навык Middle+ разработчика. Без неё компонент, размонтировавшийся до получения ответа, может обновить state и вызвать ошибку, а старый запрос перезапишет результат нового. AbortController — стандартный способ решить эти проблемы.
Содержание
- Зачем отменять запросы
- AbortController и AbortSignal
- Отмена fetch
- Отмена axios
- Отмена ky
- Отмена в React через cleanup
- Race conditions
- AbortController + debounce
- Отмена в TanStack Query
- Лучшие практики
- Антипаттерны
Зачем отменять запросы
Отмена запросов решает три проблемы:
- Экономия ресурсов. Не нужно ждать ответ, который больше не актуален.
- Предотвращение состояний-гонок (race conditions). Старый запрос не должен перезаписывать результат нового.
- Избежание утечек памяти. Компонент размонтировался, а callback запроса всё ещё может обновить state.
Классический пример: пользователь быстро переключает вкладки или вводит текст в поиск. Без отмены каждый символ порождает запрос, и ответы приходят в непредсказуемом порядке.
AbortController и AbortSignal
AbortController — стандартный браузерный API для отмены асинхронных операций. Он состоит из двух частей:
AbortController— объект, у которого есть методabort().AbortSignal— сигнал, передаваемый в запрос. Когда вызываетсяabort(), сигнал уведомляет запрос о необходимости прерваться.
const controller = new AbortController();
const signal = controller.signal;
fetch('/api/users', { signal });
// Отмена
controller.abort();
Отмена fetch
const controller = new AbortController();
try {
const response = await fetch('/api/users', {
signal: controller.signal,
});
const data = await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Запрос отменён');
} else {
console.error('Ошибка запроса:', error);
}
}
Timeout через AbortController
function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
return fetch(url, {
...options,
signal: controller.signal,
}).finally(() => clearTimeout(id));
}
Отмена axios
Современные версии axios поддерживают AbortController. Старый способ через CancelToken объявлен deprecated.
Через AbortController
const controller = new AbortController();
try {
const { data } = await axios.get('/api/users', {
signal: controller.signal,
});
} catch (error) {
if (axios.isCancel(error)) {
console.log('Запрос отменён');
}
}
// Отмена
controller.abort();
Устаревший CancelToken (не используйте в новом коде)
// ❌ Deprecated
const source = axios.CancelToken.source();
await axios.get('/api/users', { cancelToken: source.token });
source.cancel();
Отмена ky
ky использует fetch под капотом, поэтому отмена работает через AbortController.
const controller = new AbortController();
try {
const data = await ky.get('/api/users', {
signal: controller.signal,
}).json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Запрос отменён');
}
}
Отмена в React через cleanup
Самый частый сценарий — отмена запроса при размонтировании компонента или изменении зависимостей.
Простой пример
import { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((error) => {
if (error.name !== 'AbortError') {
console.error(error);
}
});
return () => {
controller.abort();
};
}, [userId]);
return <div>{user?.name}</div>;
}
С async/await
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error);
}
}
}
loadUser();
return () => controller.abort();
}, [userId]);
Race conditions
Race condition возникает, когда несколько запросов выполняются параллельно, и более старый приходит позже нового.
Пример проблемы
function Search() {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then(setResults);
}, [query]);
}
Если пользователь быстро введёт «a», затем «ab», ответ на «a» может прийти позже и перезаписать результаты для «ab».
Решение через AbortController
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((res) => res.json())
.then(setResults)
.catch((error) => {
if (error.name !== 'AbortError') {
console.error(error);
}
});
return () => controller.abort();
}, [query]);
При смене query старый запрос отменяется автоматически.
Альтернатива: флаг cancelled
useEffect(() => {
let cancelled = false;
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) {
setResults(data);
}
});
return () => {
cancelled = true;
};
}, [query]);
Флаг предотвращает обновление state, но не отменяет сам запрос. AbortController предпочтительнее, так как реально освобождает ресурсы.
AbortController + debounce
Для поиска часто комбинируют debounce и отмену запросов.
import { useEffect, useState } from 'react';
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}, 300);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск..."
/>
<ul>
{results.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Здесь:
debounceоткладывает запрос на 300 мс.AbortControllerотменяет предыдущий запрос при новом вводе.clearTimeoutотменяет ещё не начатый запрос.
Отмена в TanStack Query
TanStack Query отменяет запросы автоматически, если компонент размонтировался или queryKey изменился. Но важно, чтобы queryFn поддерживала сигнал.
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/users/${userId}`, {
signal,
});
if (!response.ok) {
throw new Error('Failed to load user');
}
return response.json();
},
});
TanStack Query передаёт signal в queryFn. Если запрос отменяется, fetch получит AbortError, который TanStack Query обработает корректно.
Отмена mutation
const mutation = useMutation({
mutationFn: async (newUser, { signal }) => {
return ky.post('/api/users', {
json: newUser,
signal,
}).json();
},
});
// Отмена
mutation.cancel();
Лучшие практики
1. Всегда используй AbortController в useEffect
useEffect(() => {
const controller = new AbortController();
// ...
return () => controller.abort();
}, []);
2. Проверяйте AbortError отдельно
catch (error) {
if (error.name === 'AbortError') {
return; // Не ошибка
}
// Реальная ошибка
}
3. Не смешивайте CancelToken и AbortController
Используйте AbortController в новом коде. CancelToken deprecated.
4. Передавайте signal в TanStack Query
Это позволяет библиотеке корректно отменять запросы.
5. Используйте AbortController для timeout
const controller = new AbortController();
setTimeout(() => controller.abort(), 10000);
6. Отменяйте запросы при навигации
Если пользователь уходит со страницы, все активные запросы должны отменяться.
Антипаттерны
1. Игнорирование cleanup
// ❌ Плохо: запрос продолжает выполняться после размонтирования
useEffect(() => {
fetch('/api/users').then(setUser);
}, []);
2. Логирование AbortError как ошибки
// ❌ Плохо
} catch (error) {
console.error(error); // AbortError здесь
}
3. Создание AbortController вне эффекта
// ❌ Плохо: один контроллер на все запросы
const controller = new AbortController();
useEffect(() => {
fetch('/api/users', { signal: controller.signal });
}, []);
Каждый запрос должен иметь свой контроллер.
4. Отмена без проверки
// ❌ Плохо: abort безусловно
controller.abort();
// ✅ Хорошо: abort в cleanup
return () => controller.abort();
5. Пренебрежение race conditions
// ❌ Плохо: старый ответ может перезаписать новый
useEffect(() => {
fetch(`/api/search?q=${query}`).then(setResults);
}, [query]);
Ключевые тезисы для интервью
- Отмена запросов экономит ресурсы, предотвращает race conditions и утечки памяти.
AbortController— стандартный браузерный API для отмены асинхронных операций.fetch,axiosиkyподдерживаютAbortControllerчерез опциюsignal.- В React запросы отменяются в
useEffectcleanup черезcontroller.abort(). - Race condition — старый ответ перезаписывает новый. AbortController решает эту проблему.
- Флаг
cancelledпредотвращает обновление state, но не отменяет сам запрос. - TanStack Query автоматически отменяет запросы — нужно передать
signalвqueryFn. AbortErrorнужно проверять отдельно и не логировать как ошибку.- Debounce + AbortController — стандартный паттерн для поиска.
Заключение
Отмена запросов — обязательный навык Middle+ разработчика.
AbortController— стандартный способ отмены дляfetch,axios,ky.- В React отменяйте запросы в
useEffectcleanup. AbortControllerрешает проблему race conditions при быстрой смене зависимостей.- TanStack Query поддерживает сигнал — передавайте его в
queryFn. - Всегда разделяйте
AbortErrorи реальные ошибки.