Проектирование хука и зависимости
Кастомный хук — это API: чёткие входы, стабильная форма возврата, честный массив зависимостей и очистка. Классические баги хуков — устаревшие замыкания и пропущенная очистка: deps и cleanup хука И ЕСТЬ его контракт.
Вынести логику в кастомный хук — легко. Спроектировать такой, который не врёт, — вот трудная часть. Два бага, преследующих кастомные хуки, не экзотичны: устаревшее замыкание, тихо срабатывающее со значениями прошлого рендера, и пропущенная очистка, текущая интервалом, слушателем или подпиской. Оба родом из одного места — массива зависимостей и очистки эффекта, тех двух вещей, что считают шаблонным мусором.
Это не шаблонный мусор. Массив зависимостей хука и его очистка и есть его контракт: они объявляют, что хук читает и что обещает разрушить. Сделай их верно — и хук станет чёрным ящиком, которому коллега может доверять. Сделай их неверно — и ты отгрузил бомбу замедленного действия, которая срабатывает, только когда проп меняется в неудачный момент.
После этого урока ты можешь спроектировать кастомный хук как настоящий API — чёткие входы, стабильную и предсказуемую форму возврата, честный массив зависимостей и очистку, симметричную настройке; объяснить, почему пропущенный или врущий массив зависимостей порождает устаревшие замыкания и утечки; использовать ref, чтобы держать свежий колбэк, не переподписываясь каждый рендер; и решать, когда возвращаемой функции действительно нужен useCallback, а когда мемоизация «на всякий случай» — просто шум.
Сначала спроектируй поверхность хука: чёткие входы, предсказуемая форма возврата. До любого эффекта реши, что входит и что выходит, — и держи форму возврата стабильной по рендерам, чтобы потребители деструктурировали её один раз. Возвращай кортеж, когда порядок очевиден и мал ([value, setValue]), и объект, когда есть несколько именованных частей (чтобы вызывающие брали нужное без позиционного угадывания).
// возврат объектом: именованный, расширяемый, независимый от порядка
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return { on, toggle, setOn }; // те же ключи каждый рендер → стабильная форма
}Предсказуемая форма — часть контракта. Если хук иногда возвращает undefined, а иногда объект, каждому потребителю приходится защищаться. Выбери форму и держи её для каждого рендера, включая первый.
Массив зависимостей — это заявление: «вот значения, которые этот эффект читает». Врущий массив зависимостей и есть баг. exhaustive-deps не зануда — он сверяет твоё заявление с реальностью. Если эффект замыкается над delay, а ты пишешь [], ты сказал React «этот эффект ничего не читает», поэтому он никогда не перезапустится при изменении delay, и интервал вечно срабатывает с первоначальным delay. Это устаревшее замыкание: эффект захватил значения нулевого рендера, и ему никогда не велели обновиться.
// ВРУЩИЙ массив зависимостей: эффект читает `delay`, но заявляет, что ничего не читает
useEffect(() => {
const id = setInterval(tick, delay); // захватывает ПЕРВЫЙ delay
return () => clearInterval(id);
}, []); // ❌ пропускает `delay` → никогда не обновляется при смене delayИсправление никогда не «заглуши правило линтера». Это либо честно перечислить зависимость (и принять переподписку), либо перестроить так, чтобы эффект и правда от неё не зависел, — что именно и даёт ref в шаге 4.
Очистка — вторая половина контракта: каждую подписку, которую ты настроил, ты разрушаешь. Эффект, добавляющий слушателя, открывающий интервал или подписывающийся на стор, обязан вернуть функцию, которая всё это отменяет. Пропусти её — и потечёт: дублирующиеся слушатели накапливаются по перерисовкам и размонтированиям, интервалы тикают после исчезновения компонента, и ты получаешь предупреждения «setState на размонтированном компоненте» или, хуже, тихие двойные срабатывания.
function useEventListener(type: string, handler: (e: Event) => void) {
useEffect(() => {
window.addEventListener(type, handler);
return () => window.removeEventListener(type, handler); // ← контракт
}, [type, handler]); // переподписывается, если меняется любое
}Здесь скрытая ловушка: handler в deps, поэтому инлайн-обработчик (новая идентичность каждый рендер) заставляет это переподписываться каждый рендер. Это корректно, но расточительно — и это та самая нагрузка, что оправдывает паттерн с ref далее.
Используй ref, чтобы читать свежий колбэк без переподписки, — senior-исправление напряжения «устаревшее замыкание против churn». Тебе нужны две вещи, которые борются друг с другом: эффект всегда должен вызывать свежий обработчик (нет устаревшего замыкания), но он не должен разрушать и пересоздавать подписку каждый рендер. Ref разрешает это: храни свежайший обработчик в ref (обновляемый каждый рендер), подпишись один раз стабильной обёрткой, читающей ref.current в момент вызова.
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; }); // всегда свежий, без churn
useEffect(() => {
if (delay === null) return; // null = пауза
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id); // очистка симметрична настройке
}, [delay]); // ✅ честно: только delay реально движет переподпиской
}Теперь delay — единственное, что переподписывает, колбэк никогда не устаревает, а массив зависимостей говорит правду. Это и есть весь паттерн: ref поглощает потребность в «свежем значении», так что массив зависимостей может оставаться и честным, и минимальным.
useEventListener, который течёт и устаревает, превращённый в тот, что нет. Коллега пишет хук, чтобы отслеживать свежайшую позицию прокрутки и вызывать обработчик. Первая версия выглядит нормально и отгружается:
// ДО: устаревший обработчик + переподписка каждый рендер
function useScroll(onScroll: (y: number) => void) {
useEffect(() => {
const fn = () => onScroll(window.scrollY);
window.addEventListener("scroll", fn);
return () => window.removeEventListener("scroll", fn);
}, []); // ❌ deps врут: эффект читает onScroll, но заявляет, что ничего
}Два бага из одной причины. [] говорит «ничего не читает», поэтому onScroll заморожен в замыкании первого рендера — если родитель передаёт новый onScroll (скажем, со свежим состоянием), слушатель всё ещё вызывает старый. А если коллега «исправит» линт, написав [onScroll], инлайн-обработчик теперь добавляет и удаляет слушателя на каждом единственном рендере. Честная версия без churn использует ref:
// ПОСЛЕ: ref поглощает свежий обработчик; deps говорят правду
function useScroll(onScroll: (y: number) => void) {
const saved = useRef(onScroll);
useEffect(() => { saved.current = onScroll; }); // свежий каждый рендер, без churn
useEffect(() => {
const fn = () => saved.current(window.scrollY); // читает свежий в момент вызова
window.addEventListener("scroll", fn);
return () => window.removeEventListener("scroll", fn);
}, []); // ✅ честно: эффект подписки и правда не читает реактивного значения
}Здесь [] теперь истинно — эффект подписки действительно не читает ничего реактивного; свежий обработчик приходит через ref, а не через замыкание. Настройка и очистка симметричны, обработчик никогда не устаревает, а слушатель добавляется ровно один раз. Эта симметрия — deps, что не врут, очистка, зеркалящая настройку, — и есть контракт, который кастомный хук должен своим потребителям.
▸Частая ошибка
Ловушка «перемемоизировать всё на всякий случай». Оборачивать каждую возвращаемую функцию в useCallback, а каждый возвращаемый объект в useMemo кажется защитой, но большинство этих мемо — чистая стоимость: они выполняются каждый рендер, выделяют массив deps и ничего не покупают, если ниже по потоку нет чувствительного к идентичности потребителя (зависимость эффекта, memo-нутый ребёнок, массив deps другого хука). Стабильная идентичность стоит оплаты только тогда, когда от неё что-то реально зависит. Мемоизируй возвращаемую функцию потому что потребитель кладёт её в массив deps или передаёт в React.memo, — а не рефлекторно. Зеркальная ошибка хуже: врущий [] массив зависимостей ради обхода предупреждения линтера. Перемемоизация тратит циклы; врущий массив зависимостей отгружает баг устаревшего замыкания.
▸Граничные случаи
useInterval с delay === null как сигналом паузы — намеренное решение API, достойное копирования: nullable-вход, на котором эффект делает ранний возврат, позволяет потребителю ставить на паузу и возобновлять декларативно (useInterval(tick, isRunning ? 1000 : null)), не вынуждая тебя выставлять императивные методы start/stop. Очистка всё равно срабатывает, когда delay переключается на null, так что старый интервал очищается — пауза это просто «разруши, ничего не настраивай». Заложить отсутствие работы в форму входа — значит держать поверхность возврата маленькой, а контракт честным.
Хук useInterval захватывает `callback` прямо внутри эффекта и использует [delay] как массив зависимостей, но родитель передаёт свежий инлайн-`callback` каждый рендер, читающий текущее состояние. В чём баг и какое правильное исправление?
Кастомный хук — это API, и его контракт составляют четыре вещи. Входы должны быть чёткими, а форма возврата стабильной и предсказуемой, чтобы потребители деструктурировали её один раз. Массив зависимостей — это заявление о том, что читает эффект; exhaustive-deps сверяет это заявление, а врущий [] порождает устаревшее замыкание, срабатывающее с замороженными значениями. Очистка зеркалит настройку: каждый слушатель, интервал или подписку, что ты создаёшь, ты разрушаешь, иначе течёт. Senior-ход для напряжения «устаревшее замыкание против churn» — ref: пиши свежий колбэк в ref.current каждый рендер, затем подпишись один раз с deps, содержащими только реальный конфиг (delay, type), — честно и минимально. И сопротивляйся рефлексу обвешивать всё useCallback/useMemo: стабильная идентичность стоит оплаты только тогда, когда от неё реально зависит потребитель ниже по потоку. Deps и cleanup — не шаблонный мусор, это обещание, которое даёт твой хук.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.