Управление асинхронными операциями в React: от хаоса к порядку с кастомным хуком

Сколько раз вы сталкивались с подобным кодом в React-компонентах?

typescript
const Component = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      try {
        const response = await fetch('/api/data');
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return <div>{JSON.stringify(data)}</div>;
};

Такой подход знаком большинству разработчиков, но он содержит несколько скрытых проблем:

  1. Возможные утечки памяти (setState на размонтированном компоненте)
  2. Отсутствие отмены запросов
  3. Повторная реализация одинаковой логики в разных компонентах
  4. Трудности в обработке параллельных или последовательных запросов
  5. Ограниченная подключаемость в жизненный цикл компонента

Разберёмся, как перейти от этого шаблонного кода к элегантному решению через создание собственного хука.

Анатомия идеального асинхронного хука

Нам нужен хук, который будет:

  1. Управлять состоянием загрузки, ошибки и результата
  2. Предоставлять возможность явного запуска операции
  3. Поддерживать отмену операций при размонтировании
  4. Предотвращать обновления несуществующих компонентов
  5. Позволять расширять базовый функционал

Реализуем useAsyncTask:

typescript
import { useState, useEffect, useCallback, useRef } from 'react';

type AsyncTaskFunction<T> = (signal: AbortSignal, ...args: any[]) => Promise<T>;

type AsyncTaskState<T> = {
  data: T | null;
  error: Error | null;
  status: 'idle' | 'pending' | 'success' | 'error';
};

type UseAsyncTaskResult<T> = AsyncTaskState<T> & {
  execute: (...args: any[]) => Promise<void>;
  reset: () => void;
  isIdle: boolean;
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
};

function useAsyncTask<T>(
  asyncFunction: AsyncTaskFunction<T>
): UseAsyncTaskResult<T> {
  const [state, setState] = useState<AsyncTaskState<T>>({
    data: null,
    error: null,
    status: 'idle',
  });

  const abortControllerRef = useRef<AbortController | null>(null);
  
  // Флаг для предотвращения обновления после размонтирования
  const mountedRef = useRef(false);

  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
      // Отменяем запрос при размонтировании
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, []);

  const safeSetState = useCallback(
    (updater: (prevState: AsyncTaskState<T>) => AsyncTaskState<T>) => {
      if (mountedRef.current) {
        setState(updater);
      }
    }, 
    []
  );

  const execute = useCallback(
    async (...args: any[]) => {
      // Отменяем предыдущий запрос, если он существует
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      const abortController = new AbortController();
      abortControllerRef.current = abortController;

      safeSetState((prev) => ({
        ...prev,
        status: 'pending',
        error: null,
      }));

      try {
        const result = await asyncFunction(abortController.signal, ...args);
        
        if (!abortController.signal.aborted) {
          safeSetState({
            data: result,
            error: null,
            status: 'success',
          });
        }
      } catch (error) {
        if (!abortController.signal.aborted) {
          safeSetState({
            data: null,
            error: error instanceof Error ? error : new Error(String(error)),
            status: 'error',
          });
        }
      } finally {
        if (abortControllerRef.current === abortController) {
          abortControllerRef.current = null;
        }
      }
    },
    [asyncFunction, safeSetState]
  );

  const reset = useCallback(() => {
    safeSetState({
      data: null,
      error: null,
      status: 'idle',
    });
  }, [safeSetState]);

  return {
    ...state,
    execute,
    reset,
    isIdle: state.status === 'idle',
    isLoading: state.status === 'pending',
    isSuccess: state.status === 'success',
    isError: state.status === 'error',
  };
}

Интеграция с реальным компонентом

Посмотрим, как упрощается наш изначальный компонент:

typescript
const UserProfile = ({ userId }) => {
  const {
    data: user,
    isLoading,
    error,
    execute: fetchUser,
  } = useAsyncTask<User>(async (signal) => {
    const response = await fetch(`/api/users/${userId}`, { signal });
    if (!response.ok) throw new Error('Failed to fetch user');
    return response.json();
  });

  // Опциональный авто-запуск при изменении userId
  useEffect(() => {
    fetchUser();
  }, [userId, fetchUser]);

  if (isLoading) return <SkeletonProfile />;
  if (error) return <ErrorMessage onRetry={fetchUser} error={error} />;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
      <p>Location: {user.location}</p>
    </div>
  );
};

Ключевые преимущества реализации:

  1. Автоматическая отмена: При уменьнтировании компонента либо при новом вызове execute, предыдущий запрос отменяется через AbortController
  2. Защита от утечек: Флаг mountedRef предотвращает попытки обновления состояния размонтированного компонента
  3. Гибкость: Асинхронная функция принимает дополнительные аргументы через execute(...args)
  4. Чистая архитектура: Состояние чётко структурировано с понятными булевыми флагами
  5. Соответствие канонам React: Сохраняем иммутабельность состояния через колбэки

Реальные сложности и способы их решения

На практике простой реализации становится недостаточно. Рассмотрим расширенный сценарий:

Оптимистичные обновления

Обработка UI-изменений до подтверждения от сервера:

typescript
const updateUser = useAsyncTask<User>(async (signal, userId, updates) => {
  // Оптимистичное обновление
  setState(prev => ({ ...prev, data: { ...prev.data, ...updates } }));
  
  const response = await fetch(`/api/users/${userId}`, {
    method: 'PUT',
    body: JSON.stringify(updates),
    signal
  });

  // ... обработка ответа ...
});

Пакетная обработка запросов

Одновременное выполнение нескольких операций с агрегацией результатов:

typescript
const useAsyncAll = (tasks) => {
  const [state, setState] = useState(/* ... */);
  
  const executeAll = useCallback(async () => {
    const controller = new AbortController();
    const results = await Promise.allSettled(
      tasks.map(task => task(controller.signal))
    );
    
    // Агрегация данных и ошибок
    // ...
  }, [tasks]);

  return [state, executeAll];
};

Настраиваемые политики кэширования

Базовое кэширование с поддержкой времени жизни данных:

typescript
import createCache from 'stale-while-revalidate';

const cache = createCache({ maxAge: 5 * 60 * 1000 }); // 5 минут

const enhancedUseAsyncTask = <T>(task: AsyncTaskFunction<T>, key: string) => {
  const baseTask = useAsyncTask<T>();
  
  const execute = useCallback(async (...args) => {
    if (cache.has(key)) {
      const cached = cache.get(key);
      baseTask.setState({
        data: cached,
        status: 'success',
        error: null
      });
    }
    
    await baseTask.execute(...args);
    
    if (baseTask.isSuccess) {
      cache.set(key, baseTask.data);
    }
  }, [key, baseTask]);

  return { ...baseTask, execute };
};

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

Хотя наш хук эффективен, для сложных сценариев рассмотрите специализированные библиотеки:

БиблиотекаКейсы примененияОсобенности
React QueryКомплексный кэширование, синхронизацияВстроенный DevTools
SWRНепрерывное обновление данныхАктуализация при фокусе
RTK QueryИспользование в Redux-экосистемеГенерация хуков из эндпоинтов
SuspenseЭкспериментальный подход ReactИнтеграция с Error Boundaries

Правильный выбор архитектуры

  1. Локальное состояние компонента: useAsyncTask идеален для изолированных операций в пределах UI-компонента
  2. Мутации: Для изменения данных на сервере добавьте аналогичный хук с модификаторами запросов
  3. Глобальное состояние: При обновлении данных, влияющих на несколько компонентов, объединяйте с контекстом
  4. Критическая производительность: Реализуйте стратегии invalidation через Context API + хук

Образец реальной задачи: регистрация пользователя

Совместим несколько асинхронных операций:

typescript
const RegistrationForm = () => {
  // Проверка доступности логина
  const checkUsername = useAsyncTask(async (signal, username) => {
    const res = await fetch(`/api/check-username?q=${username}`, { signal });
    return res.json().available;
  });
  
  // Фактическая регистрация
  const register = useAsyncTask(async (signal, userData) => {
    // Валидация перед отправкой
    if (!isUserDataValid(userData)) {
      throw new ValidationError('Invalid form data');
    }
    
    const res = await fetch('/api/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(userData),
      signal
    });
    
    return res.json();
  });

  const handleSubmit = async (values) => {
    // Проверяем доступность логина
    try {
      const available = await checkUsername.execute(values.username);
      if (!available) {
        throw new UserError('Username taken');
      }
      
      // Регистрация
      await register.execute(values);
    } catch (e) {
      // Уже обработано в состояниях хуков
    }
  };

  // Рендер состояния и формы
};

В этом примере комбинируются несколько асинхронных операций с обработкой зависимости между ними.

Распространённые ошибки при использовании хуков

  1. Пересоздание функции: Отсутствие мемоизации асинхронной функции приводит к бесконечным перезапросам. Используйте useCallback
typescript
// Проблема: функция создаётся заново при каждом рендере
const fetchItems = () => fetch('/api/items');

// Решение: мемоизировать функцию
const fetchItems = useCallback(() => fetch('/api/items'), []);
  1. Игнорирование утечек: Запросы, не отменяемые при размонтировании
  2. Обработка выхода за пределы формы: Необходим механизм сброса состояния между страницами
  3. Перемещение булевых флагов: Используйте isLoading вместо status === 'pending' для удобства
  4. Отсутствие rollback при оптимистичных обновлениях

Эволюция архитектуры

По мере роста приложения трансформируйте базовый хук:

typescript
// Расширение для обработки событий жизненного цикла
const useEnhancedAsyncTask = <T>(task: AsyncTaskFunction<T>, hooks: {
  onStart?: () => void;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
  onSettled?: () => void;
}) => {
  const baseTask = useAsyncTask<T>(async (signal, ...args) => {
    hooks.onStart?.();
    try {
      const data = await task(signal, ...args);
      hooks.onSuccess?.(data);
      return data;
    } catch (error) {
      hooks.onError?.(error);
      throw error;
    } finally {
      hooks.onSettled?.();
    }
  });
  
  return baseTask;
};

Вывод

Умное управление асинхронными операциями — краеугольный камень современных React-приложений. Предложенный useAsyncTask не просто сокращает шаблонный код, он создаёт надёжную структуру, соответствующую принципам устойчивой архитектуры.

Ключевые принципы для запоминания:

  1. Инкапсулируйте состояние: Загрузка, данные и ошибки — единое состояние
  2. Отменяйте запросы: Прежде чем начинать новую операцию, прервите предыдущую
  3. Помните про размонтирование: Вывешивайте "флажок неактивности"
  4. Предусматривайте расширение: События жизненного цикла, кэширование, колбэки
  5. Не бойтесь библиотек: Для сложных кейсов React Query, RTK Query — отличный выбор

Следуя этим принципам, вы преобразуете хаос асинхронных операций в предсказуемый поток данных, повышая как производительность приложения, так и удовольствие от его разработки.