Вынос кастомного хука
Кастомный хук именует и изолирует срез логики с состоянием, делая его переиспользуемым и тестируемым отдельно. Выноси, когда повторяется та же связка состояние+эффект или есть ясная именуемая цель, — но не ради единственного useState в новом имени.
Ты написал поле поиска. Оно дебаунсит запрос, грузит результаты, отслеживает загрузку и ошибку и отменяет текущий запрос на следующем нажатии клавиши. Двенадцать строк useState и useEffect сидят внутри компонента. Оно работает. Потом второму экрану — выбору тегов — нужен ровно тот же танец дебаунс-загрузка-отмена, и ты ловишь себя на копипасте этих двенадцати строк. Эта вставка и есть сигнал.
Кастомный хук — это просто функция, имя которой начинается с use и которая вызывает другие хуки. Но смысл его не в переиспользовании ради переиспользования — а в том, что ты можешь взять запутанный срез логики с состоянием, дать ему имя вроде useDebounce или useUser и вынести из компонента, чтобы его можно было переиспользовать, читать и тестировать отдельно. Навык — не написать хук. Навык — понять, какие срезы заслуживают им стать.
После этого урока ты можешь распознать два сигнала, оправдывающих вынос кастомного хука, — повторяющаяся логика состояние+эффект в 2+ компонентах или срез с ясной именуемой целью, — и превратить инлайн-код useState/useEffect в именованный хук вроде useDebounce(value) или useUser(id). Ты также видишь режим отказа: хук с единственным потребителем, который есть просто переименованный useState, — он добавляет косвенность, не давая ни переиспользования, ни ясности.
Кастомный хук именует и изолирует срез логики с состоянием — и это именование и есть настоящий продукт. Компоненты отвечают на вопрос «что это рендерит?». Кастомный хук отвечает на вопрос «какое поведение с состоянием у этого есть?». Когда ты вызываешь 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+ компонентах — дублирование самый честный сигнал. Два компонента, делающих идентичный танец дебаунс-загрузка-отмена, — это гарантия, что у логики есть форма, достойная имени. Выигрыш конкретен и двойной: одно место, где править баг (забытую очистку, неверную зависимость), вместо двух, и имя, позволяющее каждому будущему читателю не выводить механизм заново.
// 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, не дающий устаревшему ответу перезаписать более свежий, живёт ровно в одном месте — поправь однажды, выигрывают оба потребителя.
Выноси, когда у среза есть ясная, именуемая цель — даже при единственном потребителе, — потому что имя покупает ясность, а не переиспользование. Переиспользование — частый триггер, но не единственный валидный. Если компонент смешивает свою заботу о рендеринге с самодостаточным поведением, у которого есть имя — «отслеживать, онлайн ли окно», «прочитать это из 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. Концепция заслужила своё имя.
Режим отказа: хук с единственным потребителем, который есть просто переименованный 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.