Асинхронные ловушки в React: Управление жизненным циклом запросов без боли

Представьте такую сцену: пользователь вашего React-приложения быстро переключается между страницами, пока компоненты в фоне продолжают запрашивать данные. Сервер получает десятки ненужных запросов, интерфейс «дергается», а в консоли мелькает предупреждение: «Can't perform state update on unmounted component». Знакомо? Эта ситуация — лишь верхушка айсберга проблем управления асинхронными операциями в современных веб-приложениях.

Главный враг — утечка состояний

Классическая ошибка новичков выглядит так:

javascript
function UserProfile({ userId }) {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(response => response.json())
      .then(data => setUserData(data));
  }, [userId]);

  return <div>{userData?.name}</div>;
}

При быстром изменении userId или уходе со страницы компонент размонтируется, но Promise продолжит выполнение. Когда он разрешится, setUserData попытается обновить несуществующий компонент. Последствия:

  1. Утечка памяти
  2. Возможные ошибки выполнения
  3. Некорректная логика (обновление устаревшего состояния)

AbortController — не просто галочка в чек-листе

Современное решение использует интеграцию с Web API:

javascript
useEffect(() => {
  const controller = new AbortController();
  
  const loadData = async () => {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal
      });
      const data = await response.json();
      setUserData(data);
    } catch (err) {
      if (err.name !== 'AbortError') {
        // Реальная обработка ошибок, а не фантомные исключения
        console.error('Fetch failed:', err);
      }
    }
  };

  loadData();
  
  return () => controller.abort();
}, [userId]);

Здесь есть нюансы:

  • AbortController.signal совместим не только с fetch, но и с axios, fetch-полями, WebSockets
  • Прерванные запросы выбрасывают DOMException с именем 'AbortError'
  • Отмена работает только для текущих запросов, не влияет на кэшированные данные

Оптимизации уровня предприятия

Для сложных сценариев рассмотрите стратегии:

Дебаунс автозаполнения
Комбинируйте useEffect с setTimeout/clearTimeout, чтобы избежать лавины запросов:

javascript
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);

useEffect(() => {
  const handler = setTimeout(() => {
    if (searchTerm) {
      fetchResults(searchTerm).then(setResults);
    }
  }, 300);

  return () => clearTimeout(handler);
}, [searchTerm]);

Стратегия SWR (Stale-While-Revalidate)
Библиотеки типа react-query используют фоновую перевалидацию данных:

javascript
const { data, error } = useQuery(['user', userId], () => 
  fetch(`/users/${userId}`).then(res => res.json()), {
    staleTime: 60 * 1000 // Перезапрашивать не чаще раза в минуту
  });

Отслеживание монтирования
Возможен паттерн с флагом монтирования для legacy-кода:

javascript
useEffect(() => {
  let isMounted = true;
  
  fetchData().then(data => {
    if (isMounted) {
      setState(data);
    }
  });

  return () => { isMounted = false; };
}, []);

Когда производительность имеет значение

Кейс: таблица с сортировкой, фильтрацией и пагинацией. Интуитивная реализация запускает новый запрос при каждом изменении параметров, но:

  • Отклоненные Promise могут вызывать состояние гонки
  • Быстрые клики создают «конфликтующие» запросы

Решение — атомарные обновления с проверкой актуальности:

javascript
function useCancellableQuery() {
  const lastController = useRef(null);

  async function query(url) {
    if (lastController.current) {
      lastController.current.abort();
    }
    
    const controller = new AbortController();
    lastController.current = controller;
    
    try {
      const response = await fetch(url, { signal: controller.signal });
      return await response.json();
    } finally {
      if (lastController.current === controller) {
        lastController.current = null;
      }
    }
  }
  
  return query;
}

Момент истины: что выбрать?

Чистый React

  • Подходит для простых случаев
  • Полный контроль над потоком выполнения
  • Риск изобретения велосипедов

Сторонние библиотеки (react-query, SWR)

  • Встроенная кэш-логика
  • Автоматический повтор запросов
  • Префетчинг и оптимистичные обновления
  • Дополнительный размер бандла

Чего нельзя игнорировать

  1. При интеграции Web Workers или WebSockets используйте явную отписку:

    javascript
    useEffect(() => {
      const ws = new WebSocket(url);
      ws.onmessage = handleMessage;
      return () => ws.close();
    }, []);
    
  2. Для анимаций и сложных вычислений применяйте двойную проверку монтирования:

    javascript
    const animationFrame = useRef(null);
    
    useEffect(() => {
      let active = true;
    
      function tick() {
        if (!active) return;
        // Вычисления
        animationFrame.current = requestAnimationFrame(tick);
      }
    
      tick();
      return () => {
        active = false;
        cancelAnimationFrame(animationFrame.current);
      };
    }, []);
    
  3. В TypeScript аннотируйте ошибки корректно:

    typescript
    catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        // Игнорируем отмену
      } else {
        // Обработка реальных ошибок
      }
    }
    

Грамотное управление асинхронными операциями превращает хаотичную цепочку запросов в предсказуемый механизм. Выбор между native-подходом и готовым решением — не вопрос догмы, а прагматичный анализ конкретного кейса. Главное — помнить, что каждая асинхронная операция должна иметь четкий сценарий прерывания. Современный фронтенд — это не только умение запускать процессы, но и искусство их вовремя останавливать.

text