Контракты хуков, правила их вызова, инкапсуляция жизненного цикла, стабильность API и проверяемые адаптеры.
Пользовательский хук переиспользует реактивную логику и жизненный цикл, но не делает состояние общим между вызовами.
Вы выделите проверяемые хуки с узкими входами, явными результатами и корректной отменой, не превращая их в скрытый реестр служб.
Понадобятся эффекты, ref, редьюсеры, обратные вызовы и обобщения TypeScript. Итог урока — хуки useOnlineStatus, useDebouncedValue и useResource с заменяемыми адаптерами и проверками контракта.
Хуки вызывают только на верхнем уровне функционального компонента или другого хука. Нельзя вызывать их в условии, цикле, обработчике, обычной функции или после условного возврата. React сопоставляет ячейки состояния по порядку вызовов, поэтому этот порядок должен быть одинаковым для экземпляра компонента.
Условный вызов сдвигает порядок ячеек и приводит к чтению чужого состояния:
// ❌ при изменении enabled число вызовов хуков меняется
if (enabled) {
useEffect(() => subscribe(), []);
}
// ✅ хук вызывается всегда, условие живёт внутри
useEffect(() => {
if (!enabled) return;
const subscription = subscribe();
return () => subscription.unsubscribe();
}, [enabled]);Тот же приём работает для условного возврата: если после if (!data) return null; идут вызовы хуков, порядок нарушен. Проверку выносят ниже всех хуков либо разделяют компонент на два.
Проверьте себя. Перепишите условный хук так, чтобы условие находилось внутри эффекта или в отдельном компоненте.
Частая ошибка. if (enabled) useEffect(...).
Функция с префиксом use считается хуком и может вызывать другие хуки; обычной утилите такой префикс не нужен. Название описывает возможность (useOnlineStatus), а сигнатура — реактивные входы и результат. Не скрывайте обязательную зависимость в глобальном объекте, если её можно передать параметром или через провайдер.
Разделение чистой функции и хука делает первую тривиально проверяемой:
// src/features/search/parseQuery.ts — чистая функция, тестируется вызовом
export function parseQuery(raw: string): SearchQuery {
const trimmed = raw.trim();
return { term: trimmed, isEmpty: trimmed === '' };
}// src/features/search/useQueryState.ts — хук: состояние и реактивность
export function useQueryState(initial = '') {
const [raw, setRaw] = useState(initial);
return { query: parseQuery(raw), setRaw }; // чистая логика переиспользована
}Префикс use у обычного форматтера (useFormatDate) вводит в заблуждение и линтер, и читателя: правила хуков начнут применяться к функции, которая может вызываться откуда угодно, а предупреждения станут ложными.
Проверьте себя. Разделите чистую функцию parseQuery и хук useQueryState.
Частая ошибка. Назвать обычный formatter useFormatDate, вводя lint и читателя в заблуждение.
Пользовательский хук переиспользует код, а не состояние. Два вызова useOnlineStatus создадут две подписки, если хук так написан. Для одного общего внешнего источника лучше useSyncExternalStore. Если несколько компонентов должны делить черновик, состояние поднимают к общему владельцу.
Независимость легко проверить двумя вызовами в одном компоненте:
const left = useCounter(); // своё значение
const right = useCounter(); // своё значение, никак не связанное с leftДля внешнего источника, который должен быть один, используют подписку вместо копии состояния:
// src/shared/hooks/useOnlineStatus.ts
export function useOnlineStatus(): boolean {
return useSyncExternalStore(
(onChange) => {
window.addEventListener('online', onChange);
window.addEventListener('offline', onChange);
return () => {
window.removeEventListener('online', onChange);
window.removeEventListener('offline', onChange);
};
},
() => navigator.onLine, // значение в браузере
() => true, // значение на сервере: без него SSR упадёт
);
}Здесь состояние остаётся во внешней системе, поэтому все вызовы хука читают один и тот же источник — и при этом React корректно обрабатывает конкурентную отрисовку. Третий аргумент обязателен для SSR: navigator на сервере не существует.
Проверьте себя. Создайте два useCounter и докажите независимость значений.
Частая ошибка. Ожидать, что пользовательский хук автоматически создаёт единое общее хранилище.
Хук возвращает предметную модель и команды. Прямые функции записи раскрывают внутреннюю структуру и позволяют недопустимые переходы. Объект результата читаем, но новая ссылка может влиять на потребителей; не добавляйте мемоизацию без причины. Кортеж подходит для небольшой устойчивой пары вроде [value, setValue].
Набор функций записи заставляет каждый компонент повторять рабочий сценарий целиком:
// ❌ вызывающий обязан помнить порядок и не забыть сбросить ошибку
const { setStatus, setError, setItems, setAttempt } = useSearch();Команды выражают намерение и не дают перейти в недопустимое состояние:
// ✅ src/features/search/useSearch.ts
export function useSearch(client: SearchClient) {
const [state, dispatch] = useReducer(searchReducer, { status: 'idle' });
return {
state, // размеченное объединение
search: (term: string) => dispatch({ type: 'search', term }),
retry: () => dispatch({ type: 'retry' }),
cancel: () => dispatch({ type: 'cancel' }),
};
}Переход «сбросить ошибку и начать новый запрос» теперь описан один раз внутри редьюсера. Компонент не может выполнить половину сценария, потому что у него просто нет такой возможности.
Проверьте себя. Замените setState на команды повтора, отмены и выбора.
Частая ошибка. Вернуть десять функций записи и заставить каждый компонент повторять один рабочий сценарий.
Хук можно связать с API через узкий интерфейс или функцию. Тогда границу браузера и сети легко заменить в тесте без подмены модулей всего приложения. Ссылка на адаптер должна быть стабильной для зависимостей эффекта; рабочий адаптер обычно экспортируется из модуля или передаётся через контекст.
type SearchClient = {
search(query: string, signal: AbortSignal): Promise<readonly Product[]>;
};
function useProductSearch(client: SearchClient, query: string) { /* lifecycle */ }Интерфейс описывает ровно нужную возможность и позволяет сделать предсказуемую тестовую реализацию с управляемыми Promise:
// src/features/search/testing.ts
export function createControllableClient() {
const pending: Array<(items: Product[]) => void> = [];
return {
client: {
search: () => new Promise<Product[]>((resolve) => { pending.push(resolve); }),
} satisfies SearchClient,
// завершаем запросы в любом порядке: так проверяется правило «последний побеждает»
resolveAt: (index: number, items: Product[]) => pending[index](items),
};
}Такая реализация даёт полный контроль над порядком ответов, чего не даёт ни задержка, ни подмена модуля целиком. Импорт большого глобального клиента прямо в хук закрывает эту возможность: в тесте придётся подменять модуль и надеяться, что он не используется где-то ещё.
Проверьте себя. Подставьте тестовую реализацию, которая завершает два запроса в обратном порядке.
Частая ошибка. Импортировать большой глобальный клиент API прямо в обобщённый хук.
Хук задержки (debounce) хранит отложенное значение и очищает таймер при смене входа. Поисковый хук отдельно отменяет запрос. Не смешивайте задержку ввода, кеш данных и состояние интерфейса в одном необозримом хуке. Управляемые таймеры проверяют задержку, а управляемые Promise — порядок ответов; это разные контракты.
Хук задержки занимается ровно одним: сдвигает значение во времени.
// src/shared/hooks/useDebouncedValue.ts
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer); // новый ввод отменяет прежний таймер
}, [value, delayMs]);
return debounced;
}Очистка здесь несёт весь смысл: без неё пять быстрых нажатий дадут пять обновлений вместо одного, а таймер продолжит работу после размонтирования и вызовет обновление у мёртвого компонента. Создание таймера во время отрисовки ломает то же самое: отрисовка может быть отброшена, и очистить такой таймер будет нечем.
Проверьте себя. Убедитесь, что пять быстрых вводов дают одно обновление отложенного значения.
Частая ошибка. Создавать таймер во время отрисовки или не очищать его при размонтировании.
Чистая вспомогательная функция проверяется напрямую. Реактивный хук проверяется через небольшой компонент или renderHook: входы меняются через повторную отрисовку, обновления выполняются в act, а очистка наблюдается у адаптера. Не проверяйте внутреннее состояние или число запусков эффектов, если контракт этого не обещает.
Тест меняет вход через повторную отрисовку и наблюдает результат, а не внутренности:
// src/features/search/useSearch.test.ts
const { client, resolveAt } = createControllableClient();
const { result, rerender } = renderHook(({ query }) => useProductSearch(client, query), {
initialProps: { query: 'ноут' },
});
rerender({ query: 'ноутбук' }); // второй запрос стартовал
await act(async () => resolveAt(1, [b])); // сначала отвечает новый
await act(async () => resolveAt(0, [a])); // затем устаревший
expect(result.current.items).toEqual([b]); // устаревший ответ не применёнПроверяется наблюдаемый контракт: «применяется результат последнего запроса». Число запусков эффекта под StrictMode таким контрактом не является — оно отличается между режимами разработки и рабочей сборкой, и тест на него будет ломаться без всякой регрессии.
Проверьте себя. Проверьте переходы статуса и отмену при смене запроса, не читая внутренние ref.
Частая ошибка. Считать конкретное число вызовов компонента в StrictMode продуктовым контрактом.
Хуки хорошо компонуются, если каждый имеет ясную ответственность и правила ошибок и отмены. Хук, возвращающий десятки значений и скрывающий маршрутизацию, авторизацию, кеш и интерфейс, превращается в неявный фреймворк. Координацию оставляйте в хуке сценария, а низкоуровневые хуки делайте небольшими адаптерами.
Сценарий собирается из мелких хуков, у каждого из которых одна обязанность:
// src/features/search/useSearchProducts.ts
export function useSearchProducts(client: SearchClient, raw: string) {
const query = parseQuery(raw); // чистый разбор
const debouncedTerm = useDebouncedValue(query.term, 300); // задержка ввода
const resource = useProductSearch(client, debouncedTerm); // жизненный цикл запроса
return { query, ...resource };
}Каждый слой можно заменить и протестировать отдельно: разбор — вызовом функции, задержку — управляемыми таймерами, ресурс — контролируемым клиентом. Хук вида useApp(), возвращающий всё состояние и все службы, лишает этой возможности любой компонент: он получает зависимость от всего приложения и не тестируется без него.
Проверьте себя. Разделите поиск на разбор строки, задержку ввода и жизненный цикл ресурса.
Частая ошибка. useApp() возвращает всё состояние и все сервисы приложения каждому компоненту.
Создайте useSearchProducts: задержка ввода, отмена предыдущего запроса, объединение статусов, повтор и внедряемый клиент. Хук не читает глобальный URL и не возвращает прямые функции записи. Проверьте смену запроса и размонтирование.
Работа готова, когда линтер подтверждает правила хуков, каждый вызов независим, внешние зависимости внедрены, а правило выбора актуального ответа доказано тестом.
Далее: Асинхронный интерфейс и Suspense