open atlas
↑ К треку
Паттерны React RXP · 04 · 02

Проектирование хука и зависимости

Кастомный хук — это API: чёткие входы, стабильная форма возврата, честный массив зависимостей и очистка. Классические баги хуков — устаревшие замыкания и пропущенная очистка: deps и cleanup хука И ЕСТЬ его контракт.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Вынести логику в кастомный хук — легко. Спроектировать такой, который не врёт, — вот трудная часть. Два бага, преследующих кастомные хуки, не экзотичны: устаревшее замыкание, тихо срабатывающее со значениями прошлого рендера, и пропущенная очистка, текущая интервалом, слушателем или подпиской. Оба родом из одного места — массива зависимостей и очистки эффекта, тех двух вещей, что считают шаблонным мусором.

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

Цель

После этого урока ты можешь спроектировать кастомный хук как настоящий API — чёткие входы, стабильную и предсказуемую форму возврата, честный массив зависимостей и очистку, симметричную настройке; объяснить, почему пропущенный или врущий массив зависимостей порождает устаревшие замыкания и утечки; использовать ref, чтобы держать свежий колбэк, не переподписываясь каждый рендер; и решать, когда возвращаемой функции действительно нужен useCallback, а когда мемоизация «на всякий случай» — просто шум.

1

Сначала спроектируй поверхность хука: чёткие входы, предсказуемая форма возврата. До любого эффекта реши, что входит и что выходит, — и держи форму возврата стабильной по рендерам, чтобы потребители деструктурировали её один раз. Возвращай кортеж, когда порядок очевиден и мал ([value, setValue]), и объект, когда есть несколько именованных частей (чтобы вызывающие брали нужное без позиционного угадывания).

// возврат объектом: именованный, расширяемый, независимый от порядка
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return { on, toggle, setOn }; // те же ключи каждый рендер → стабильная форма
}

Предсказуемая форма — часть контракта. Если хук иногда возвращает undefined, а иногда объект, каждому потребителю приходится защищаться. Выбери форму и держи её для каждого рендера, включая первый.

2

Массив зависимостей — это заявление: «вот значения, которые этот эффект читает». Врущий массив зависимостей и есть баг. exhaustive-deps не зануда — он сверяет твоё заявление с реальностью. Если эффект замыкается над delay, а ты пишешь [], ты сказал React «этот эффект ничего не читает», поэтому он никогда не перезапустится при изменении delay, и интервал вечно срабатывает с первоначальным delay. Это устаревшее замыкание: эффект захватил значения нулевого рендера, и ему никогда не велели обновиться.

// ВРУЩИЙ массив зависимостей: эффект читает `delay`, но заявляет, что ничего не читает
useEffect(() => {
  const id = setInterval(tick, delay); // захватывает ПЕРВЫЙ delay
  return () => clearInterval(id);
}, []); // ❌ пропускает `delay` → никогда не обновляется при смене delay

Исправление никогда не «заглуши правило линтера». Это либо честно перечислить зависимость (и принять переподписку), либо перестроить так, чтобы эффект и правда от неё не зависел, — что именно и даёт ref в шаге 4.

3

Очистка — вторая половина контракта: каждую подписку, которую ты настроил, ты разрушаешь. Эффект, добавляющий слушателя, открывающий интервал или подписывающийся на стор, обязан вернуть функцию, которая всё это отменяет. Пропусти её — и потечёт: дублирующиеся слушатели накапливаются по перерисовкам и размонтированиям, интервалы тикают после исчезновения компонента, и ты получаешь предупреждения «setState на размонтированном компоненте» или, хуже, тихие двойные срабатывания.

function useEventListener(type: string, handler: (e: Event) => void) {
  useEffect(() => {
    window.addEventListener(type, handler);
    return () => window.removeEventListener(type, handler); // ← контракт
  }, [type, handler]); // переподписывается, если меняется любое
}

Здесь скрытая ловушка: handler в deps, поэтому инлайн-обработчик (новая идентичность каждый рендер) заставляет это переподписываться каждый рендер. Это корректно, но расточительно — и это та самая нагрузка, что оправдывает паттерн с ref далее.

4

Используй 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.