Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Пользовательские хуки
custom_hooks

Пользовательские хуки

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

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

Пользовательские хуки

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

#Результат урока

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

Понадобятся эффекты, ref, редьюсеры, обратные вызовы и обобщения TypeScript. Итог урока — хуки useOnlineStatus, useDebouncedValue и useResource с заменяемыми адаптерами и проверками контракта.

#1. Правила хуков

Хуки вызывают только на верхнем уровне функционального компонента или другого хука. Нельзя вызывать их в условии, цикле, обработчике, обычной функции или после условного возврата. React сопоставляет ячейки состояния по порядку вызовов, поэтому этот порядок должен быть одинаковым для экземпляра компонента.

Условный вызов сдвигает порядок ячеек и приводит к чтению чужого состояния:

// ❌ при изменении enabled число вызовов хуков меняется if (enabled) { useEffect(() => subscribe(), []); } // ✅ хук вызывается всегда, условие живёт внутри useEffect(() => { if (!enabled) return; const subscription = subscribe(); return () => subscription.unsubscribe(); }, [enabled]);

Тот же приём работает для условного возврата: если после if (!data) return null; идут вызовы хуков, порядок нарушен. Проверку выносят ниже всех хуков либо разделяют компонент на два.

Проверьте себя. Перепишите условный хук так, чтобы условие находилось внутри эффекта или в отдельном компоненте.

Частая ошибка. if (enabled) useEffect(...).

#2. Имя и реактивный контракт

Функция с префиксом 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 и читателя в заблуждение.

#3. Каждый вызов имеет независимое состояние

Пользовательский хук переиспользует код, а не состояние. Два вызова 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 и докажите независимость значений.

Частая ошибка. Ожидать, что пользовательский хук автоматически создаёт единое общее хранилище.

#4. Узкий результат вместо функций прямой записи

Хук возвращает предметную модель и команды. Прямые функции записи раскрывают внутреннюю структуру и позволяют недопустимые переходы. Объект результата читаем, но новая ссылка может влиять на потребителей; не добавляйте мемоизацию без причины. Кортеж подходит для небольшой устойчивой пары вроде [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 на команды повтора, отмены и выбора.

Частая ошибка. Вернуть десять функций записи и заставить каждый компонент повторять один рабочий сценарий.

#5. Инъекция адаптера внешней системы

Хук можно связать с 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 прямо в обобщённый хук.

#6. Задержка, отмена и очистка

Хук задержки (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; }

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

Проверьте себя. Убедитесь, что пять быстрых вводов дают одно обновление отложенного значения.

Частая ошибка. Создавать таймер во время отрисовки или не очищать его при размонтировании.

#7. Тестирование хука через наблюдаемое поведение

Чистая вспомогательная функция проверяется напрямую. Реактивный хук проверяется через небольшой компонент или 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 продуктовым контрактом.

#8. Композиция хуков без «всемогущего» хука

Хуки хорошо компонуются, если каждый имеет ясную ответственность и правила ошибок и отмены. Хук, возвращающий десятки значений и скрывающий маршрутизацию, авторизацию, кеш и интерфейс, превращается в неявный фреймворк. Координацию оставляйте в хуке сценария, а низкоуровневые хуки делайте небольшими адаптерами.

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

// 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 и не возвращает прямые функции записи. Проверьте смену запроса и размонтирование.

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

#Источники для сверки

  • React: Reusing Logic with Custom Hooks
  • React: Rules of Hooks
  • React eslint-plugin-react-hooks

Устранение неисправностей

Далее: Асинхронный интерфейс и Suspense