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

Вынос кастомного хука

Кастомный хук именует и изолирует срез логики с состоянием, делая его переиспользуемым и тестируемым отдельно. Выноси, когда повторяется та же связка состояние+эффект или есть ясная именуемая цель, — но не ради единственного useState в новом имени.

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

Ты написал поле поиска. Оно дебаунсит запрос, грузит результаты, отслеживает загрузку и ошибку и отменяет текущий запрос на следующем нажатии клавиши. Двенадцать строк useState и useEffect сидят внутри компонента. Оно работает. Потом второму экрану — выбору тегов — нужен ровно тот же танец дебаунс-загрузка-отмена, и ты ловишь себя на копипасте этих двенадцати строк. Эта вставка и есть сигнал.

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

Цель

После этого урока ты можешь распознать два сигнала, оправдывающих вынос кастомного хука, — повторяющаяся логика состояние+эффект в 2+ компонентах или срез с ясной именуемой целью, — и превратить инлайн-код useState/useEffect в именованный хук вроде useDebounce(value) или useUser(id). Ты также видишь режим отказа: хук с единственным потребителем, который есть просто переименованный useState, — он добавляет косвенность, не давая ни переиспользования, ни ясности.

1

Кастомный хук именует и изолирует срез логики с состоянием — и это именование и есть настоящий продукт. Компоненты отвечают на вопрос «что это рендерит?». Кастомный хук отвечает на вопрос «какое поведение с состоянием у этого есть?». Когда ты вызываешь const debounced = useDebounce(query, 300), компонент читается как предложение: дебаунсни запрос. setTimeout, очистка, массив зависимостей — всё исчезает за именем, которое говорит, что они вместе значат.

// инлайн: компонент несёт механизм
const [debounced, setDebounced] = useState(query);
useEffect(() => {
  const id = setTimeout(() => setDebounced(query), 300);
  return () => clearTimeout(id);
}, [query]);

// вынесено: компонент несёт намерение
const debounced = useDebounce(query, 300);

Ничего в том, что выполняется, не изменилось. Изменилось то, что компоненту больше не нужно знать, как работает дебаунс, чтобы читаться ясно. Хук — именованная граница вокруг поведения с состоянием.

2

Выноси, когда та же последовательность состояние+эффект+вычисление появляется в 2+ компонентах — дублирование самый честный сигнал. Два компонента, делающих идентичный танец дебаунс-загрузка-отмена, — это гарантия, что у логики есть форма, достойная имени. Выигрыш конкретен и двойной: одно место, где править баг (забытую очистку, неверную зависимость), вместо двух, и имя, позволяющее каждому будущему читателю не выводить механизм заново.

// useUser(id): тот же срез загрузки/loading/error, понадобившийся двум экранам
function useUser(id: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    let active = true;
    setLoading(true);
    fetchUser(id).then((u) => { if (active) { setUser(u); setLoading(false); } });
    return () => { active = false; }; // игнорируем устаревший ответ
  }, [id]);
  return { user, loading };
}

Теперь <ProfileHeader> и <AccountMenu> оба вызывают useUser(id). Флаг active, не дающий устаревшему ответу перезаписать более свежий, живёт ровно в одном месте — поправь однажды, выигрывают оба потребителя.

3

Выноси, когда у среза есть ясная, именуемая цель — даже при единственном потребителе, — потому что имя покупает ясность, а не переиспользование. Переиспользование — частый триггер, но не единственный валидный. Если компонент смешивает свою заботу о рендеринге с самодостаточным поведением, у которого есть имя — «отслеживать, онлайн ли окно», «прочитать это из localStorage и держать в синхронизации», — вынос делает компонент читаемым даже с одним потребителем. Тест в том, является ли срез настоящей концепцией, а не в том, есть ли у него два места вызова.

// сегодня один потребитель, но «онлайн ли мы?» — настоящая, самодостаточная концепция
function useOnlineStatus() {
  const [online, setOnline] = useState(() => navigator.onLine);
  useEffect(() => {
    const on = () => setOnline(true), off = () => setOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", off);
    };
  }, []);
  return online;
}

Компонент, который его использует, теперь говорит const online = useOnlineStatus(); вместо того, чтобы держать четыре строки addEventListener. Концепция заслужила своё имя.

4

Режим отказа: хук с единственным потребителем, который есть просто переименованный useState, — чистая косвенность, без переиспользования и без выигранной ясности. Это та ловушка, из-за которой «выноси хуки» ощущается как карго-культ. Оборачивание одного куска состояния без логики в функцию с префиксом use ничего не переиспользует (один потребитель) и ничего не проясняет (механизма, который надо прятать, не было). Ты добавил файл и слой для чтения с нулевой отдачей.

// ❌ косвенность без отдачи: это useState в костюме
function useCount() {
  const [count, setCount] = useState(0);
  return { count, setCount };
}
// потребитель теперь *труднее* читать, чем `const [count, setCount] = useState(0)`

// ✅ это проходит планку: изолирует настоящее, именуемое поведение с логикой
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const;
}

useCount ничего не прячет и ничего не переиспользует — удали его. useToggle пограничен, но защитим: он именует повторяющееся намерение и связывает логику toggle, чтобы потребители перестали переписывать апдейтер (v) => !v. Граница — это поведение или концепция, а не я положил это в функцию.

Разбор примера

Вынос useDebounce из настоящего компонента поиска. Вот «до» — ProductSearch, дебаунсящий свой запрос инлайн, с механизмом дебаунса, запутанным в рендерящий компонент.

function ProductSearch() {
  const [query, setQuery] = useState("");
  const [debounced, setDebounced] = useState(query);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(query), 300);
    return () => clearTimeout(id);
  }, [query]);

  const { results } = useSearch(debounced); // загрузка по дебаунснутому значению
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList items={results} />
    </>
  );
}

Триггер к выносу приходит в тот момент, когда второму компоненту — TagFilter — нужен тот же дебаунс. Мы именуем срез useDebounce<T> (дженерик, чтобы дебаунсить любое значение, не только строки), и механизм покидает компонент целиком.

function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

function ProductSearch() {
  const [query, setQuery] = useState("");
  const debounced = useDebounce(query, 300);
  const { results } = useSearch(debounced);
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList items={results} />
    </>
  );
}

Выпадают три выигрыша. Переиспользование: TagFilter теперь вызывает useDebounce(tag) без копипасты. Ясность: ProductSearch сжался до своей настоящей работы — отрендерить поле ввода и список по дебаунснутому значению. Тестируемость: useDebounce — чистая единица поведения, которую можно тестировать через renderHook из React Testing Library и фейковые таймеры, прокручивая время и проверяя, что возвращаемое значение обновляется, — без монтирования UI поиска. Ничего из этого не было возможно, пока setTimeout жил инлайн. Главное: вынос был вызван вторым потребителем. Если бы TagFilter так и не появился и у useDebounce был бы один потребитель, он всё равно прошёл бы планку — дебаунс это настоящая именуемая концепция, — но срез с одним состоянием без логики не прошёл бы.

Почему это работает

Почему вынос делает логику тестируемой так, как инлайн-код не делает? Потому что хук — функция со входами и выходами, ты можешь гонять его в изоляции: renderHook((p) => useUser(p), { initialProps: "u1" }), замокать fetchUser, проверить, что result.current.loading переключается в false с нужным пользователем. Защита от устаревшего ответа (флаг active) становится тем, что можно тестировать напрямую — перерендери с новым id, зарезолви старый промис последним, проверь, что новый пользователь всё равно побеждает. Пока эта логика инлайн в компоненте, единственный способ её выполнить — смонтировать весь компонент и симулировать UI, что связывает твой тест поведения с разметкой, не имеющей отношения к логике загрузки.

Частая ошибка

Соблазнительная ошибка — считать достижением «начинается с use». Хук, оборачивающий единственный useState без добавленной логики — useName, useCount, useIsOpen, возвращающий { value, setValue }, — это та переабстракция, от которой предостерегает урок: потребитель читается хуже, чем инлайн-useState, не получает переиспользования (один потребитель), а ты теперь поддерживаешь лишний файл. Не выноси на новизне или смутном чувстве, что «компоненты должны быть тонкими». Выноси на конкретном втором потребителе или на срезе с настоящей логикой и настоящим именем. Если удалить хук и заинлайнить его делает код яснее, выносить его не стоило вовсе.

Проверь себя
Викторина

Коллега выносит useSelectedTab() из одного компонента — он оборачивает единственный useState<string> и возвращает { tab, setTab } без другой логики, и никакой другой компонент его не использует. По тесту этого урока, это хороший вынос?

Итог

Кастомный хук именует и изолирует срез логики с состоянием, делая его переиспользуемым и тестируемым отдельно. Тянись к выносу по одному из двух сигналов: та же последовательность состояние+эффект+вычисление появляется в 2+ компонентах (реальное дублирование — одно место, где править баги, одно имя, чтобы читать) или единственный срез имеет ясную именуемую цель (настоящая концепция вроде useDebounce или useOnlineStatus, где имя покупает ясность даже с одним потребителем). Отдача конкретна: переиспользование без копипасты, компонент, читающийся как намерение, а не механизм, и поведение, которое можно гонять через renderHook вместо монтирования UI. Режим отказа — зеркальное отражение: хук с единственным потребителем, который есть просто переименованный useState, без логики и без второго потребителя: он добавляет файл и слой косвенности за нулевое переиспользование и нулевую ясность, и честный ход — заинлайнить его. Вынос вызван реальным дублированием или настоящей концепцией, никогда — новизной или чувством, что компоненты «должны» быть тонкими.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.