Сколько раз вы сталкивались с подобным кодом в React-компонентах?
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>;
};
Такой подход знаком большинству разработчиков, но он содержит несколько скрытых проблем:
- Возможные утечки памяти (setState на размонтированном компоненте)
- Отсутствие отмены запросов
- Повторная реализация одинаковой логики в разных компонентах
- Трудности в обработке параллельных или последовательных запросов
- Ограниченная подключаемость в жизненный цикл компонента
Разберёмся, как перейти от этого шаблонного кода к элегантному решению через создание собственного хука.
Анатомия идеального асинхронного хука
Нам нужен хук, который будет:
- Управлять состоянием загрузки, ошибки и результата
- Предоставлять возможность явного запуска операции
- Поддерживать отмену операций при размонтировании
- Предотвращать обновления несуществующих компонентов
- Позволять расширять базовый функционал
Реализуем useAsyncTask
:
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',
};
}
Интеграция с реальным компонентом
Посмотрим, как упрощается наш изначальный компонент:
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>
);
};
Ключевые преимущества реализации:
- Автоматическая отмена: При уменьнтировании компонента либо при новом вызове
execute
, предыдущий запрос отменяется через AbortController - Защита от утечек: Флаг
mountedRef
предотвращает попытки обновления состояния размонтированного компонента - Гибкость: Асинхронная функция принимает дополнительные аргументы через
execute(...args)
- Чистая архитектура: Состояние чётко структурировано с понятными булевыми флагами
- Соответствие канонам React: Сохраняем иммутабельность состояния через колбэки
Реальные сложности и способы их решения
На практике простой реализации становится недостаточно. Рассмотрим расширенный сценарий:
Оптимистичные обновления
Обработка UI-изменений до подтверждения от сервера:
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
});
// ... обработка ответа ...
});
Пакетная обработка запросов
Одновременное выполнение нескольких операций с агрегацией результатов:
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];
};
Настраиваемые политики кэширования
Базовое кэширование с поддержкой времени жизни данных:
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 |
Правильный выбор архитектуры
- Локальное состояние компонента:
useAsyncTask
идеален для изолированных операций в пределах UI-компонента - Мутации: Для изменения данных на сервере добавьте аналогичный хук с модификаторами запросов
- Глобальное состояние: При обновлении данных, влияющих на несколько компонентов, объединяйте с контекстом
- Критическая производительность: Реализуйте стратегии invalidation через Context API + хук
Образец реальной задачи: регистрация пользователя
Совместим несколько асинхронных операций:
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) {
// Уже обработано в состояниях хуков
}
};
// Рендер состояния и формы
};
В этом примере комбинируются несколько асинхронных операций с обработкой зависимости между ними.
Распространённые ошибки при использовании хуков
- Пересоздание функции: Отсутствие мемоизации асинхронной функции приводит к бесконечным перезапросам. Используйте
useCallback
// Проблема: функция создаётся заново при каждом рендере
const fetchItems = () => fetch('/api/items');
// Решение: мемоизировать функцию
const fetchItems = useCallback(() => fetch('/api/items'), []);
- Игнорирование утечек: Запросы, не отменяемые при размонтировании
- Обработка выхода за пределы формы: Необходим механизм сброса состояния между страницами
- Перемещение булевых флагов: Используйте
isLoading
вместоstatus === 'pending'
для удобства - Отсутствие rollback при оптимистичных обновлениях
Эволюция архитектуры
По мере роста приложения трансформируйте базовый хук:
// Расширение для обработки событий жизненного цикла
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
не просто сокращает шаблонный код, он создаёт надёжную структуру, соответствующую принципам устойчивой архитектуры.
Ключевые принципы для запоминания:
- Инкапсулируйте состояние: Загрузка, данные и ошибки — единое состояние
- Отменяйте запросы: Прежде чем начинать новую операцию, прервите предыдущую
- Помните про размонтирование: Вывешивайте "флажок неактивности"
- Предусматривайте расширение: События жизненного цикла, кэширование, колбэки
- Не бойтесь библиотек: Для сложных кейсов React Query, RTK Query — отличный выбор
Следуя этим принципам, вы преобразуете хаос асинхронных операций в предсказуемый поток данных, повышая как производительность приложения, так и удовольствие от его разработки.