Продуктовая команда растёт, и вместе с ней расползаются повторяющиеся куски логики: поиск с задержкой скопирован в четыре файла, работа с локальным хранилищем написана тремя людьми по-разному, а проверка сети вообще живёт в виде глобальной переменной. Копипаст расходится, правка одного экземпляра не доезжает до остальных, и один баг чинится трижды. Кастомный хук решает проблему штатными средствами React: функция с префиксом use собирает из стандартных хуков готовый блок поведения, который команда держит в общей библиотеке и вызывает из любого компонента. В статье пройден путь от правил вызова хуков до готового набора: отложенное значение, запросы с отменой, подписка на внешний стор, тесты и новинки React 19.3.1.
Правила хуков которые удерживают общую логику предсказуемой
Кастомный хук устроен обманчиво просто: обычная функция, внутри которой вызваны штатные useState, useEffect или чужие кастомные хуки. Ограничения диктует не синтаксис, а механика React: хуки привязываются к компоненту в порядке вызова, и порядок этот обязан быть одинаковым при каждом рендере. Отсюда три жёстких правила:
- хуки вызываются только на верхнем уровне компонента или другого хука, без условий, циклов и вложенных функций;
- имя функции начинается с use, иначе линтер не распознает вызовы и подсветки нарушений не будет;
- хук делится логикой, но не состоянием: каждый вызвавший его компонент получает собственные значения useState и собственные эффекты.
Отдельная дисциплина - проектирование сигнатуры. Скалярные параметры собираются в объект-опции, чтобы добавление нового поля не ломало существующие вызовы; возвращаемое значение из двух элементов отдают кортежем, из трёх и больше - объектом с именованными полями. Имя хука читается как название свойства: useCartTotal, useOnlineStatus, useDebouncedValue.
Нарушение первого правила ломает внутреннюю таблицу соответствия между вызовами и данными, и на экране появляются чужие значения. Нарушение второго правила прячет проблему от инструментов. Третье правило чаще всего понимают неверно: два компонента, использующие один хук, не делят между собой ни состояние, ни подписки, у каждого своя копия. Общее состояние между компонентами строится отдельно, через контекст или внешний стор.
Страховкой служит eslint-plugin-react-hooks версии 7.1.1: линтер подсвечивает условные вызовы, забытые зависимости в useEffect и useCallback, а также ловит вызовы хуков из обычных функций. Правило включается в конфигурацию ESLint одной строкой, и в код-ревью пропадает целый класс замечаний, потому что их находит машина.
Хук useDebounce как отправная точка командной библиотеки
Классический кандидат на вынос в библиотеку - отложенное значение для строки поиска. Пользователь печатает, запрос улетает на сервер только после паузы, и трафик не расходуется на каждый символ:
import { useEffect, useState } from "react";
function useDebouncedValue<T>(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
Разбор занимает меньше минуты. Внутри живёт обычный useState с начальным значением. Эффект ставит таймер, который через delayMs перенесёт свежее значение в состояние, а функция очистки снимает предыдущий таймер, если значение изменилось раньше. Массив зависимостей из value и delayMs заставляет эффект перезапускаться на каждое изменение. Дженерик позволяет хуку работать и со строками, и с числами, и с объектами фильтров.
Применение в компоненте поиска выглядит так:
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 400);
useEffect(() => {
if (debouncedQuery) {
searchProducts(debouncedQuery);
}
}, [debouncedQuery]);
Хук прячет таймеры внутри себя, и компоненту остаётся только чистая логика: что искать и когда. Именно такая пропорция - сигнал, что хук удался: детали механизма спрятаны, наружу торчит одно действие.
Хук для localStorage с типами и защитой от битых данных
Локальное хранилище - ещё один рассадник копипаста: избранное, фильтры каталога, последний выбранный город доставки. Хук оборачивает чтение и запись, а заодно лечит две реальные болезни: повреждённый JSON в хранилище и отсутствие самого хранилища при серверном рендере:
import { useEffect, useState } from "react";
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initial;
try {
const raw = window.localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
} catch {
return initial;
}
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
Ленивая инициализация через функцию читает хранилище один раз при первом рендере, а не на каждом обновлении экрана. Проверка typeof window страхует серверную сторону, где хранилища не существует. Блок try отдаёт начальное значение, если в хранилище лежит мусор после сбоя или чужого расширения браузера. Запись спрятана в эффект и синхронизирует хранилище при каждом изменении значения.
Конструкция as const закрепляет возвращаемое значение как кортеж фиксированной длины, удобный для деструктуризации. Такой хук живёт в библиотеке годами: снизу меняется реализация, сверху сигнатура остаётся прежней, и ни один компонент не замечает правок.
Внешние данные и отмена запросов через AbortController в хуке
Вторая частая боль - запросы к API. Без хука каждый компонент тащит одинаковый набор useState для загрузки и ошибок, а гонки запросов никто не отменяет: медленный ответ застрявшего запроса приходит позже свежего и затирает нужные данные. Хук закрывает обе проблемы сразу:
import { useEffect, useState } from "react";
type ProductsState = {
data: Product[] | null;
loading: boolean;
error: string | null;
};
function useProducts(url: string): ProductsState {
const [state, setState] = useState<ProductsState>({
data: null,
loading: true,
error: null
});
useEffect(() => {
const controller = new AbortController();
setState((prev) => ({ ...prev, loading: true }));
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then((data) =>
setState({ data, loading: false, error: null })
)
.catch((err) => {
if (err.name !== "AbortError") {
setState({ data: null, loading: false, error: String(err) });
}
});
return () => controller.abort();
}, [url]);
return state;
}
Ключевая строка спрятана в cleanup: controller.abort() срабатывает при смене url и при размонтировании компонента. Прерванный fetch завершается ошибкой с именем AbortError, которую ветка catch молча глотает, чтобы не пугать интерфейс ложной ошибкой. Это же правило выручает в строгом режиме React 18 и 19, где эффекты в разработке монтируются дважды: без отмены дев-сервер мгновенно задваивает запросы.
Зависимость от url делает хук декларативным: сменился адрес - перезапросились данные, ничего вызывать вручную не нужно. Для командной библиотеки такой хук обычно расширяют параметрами: заголовки, парсинг ответа, кеш по ключу. Но каркас остаётся тем же.
Подписка на стор через useSyncExternalStore и серверные снапшоты
Третья категория - хуки, читающие мир за пределами React: статус сети, размер окна, данные из Zustand или самописного стора. С React 18 для них есть штатный инструмент useSyncExternalStore, который берёт на себя подписку, отписку и согласование значений между рендерами:
import { useSyncExternalStore } from "react";
function useOnlineStatus(): boolean {
return useSyncExternalStore(
(callback) => {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
},
() => navigator.onLine,
() => true
);
}
Первый аргумент - функция подписки: React вызывает её при монтировании и передаёт колбек, который сам дёргает при изменениях, а возвращённая функция используется для отписки. Второй аргумент отдаёт текущее значение, третий - снапшот для серверного рендера, где окна и сети просто нет. Подписка, отписка и гонки состояний ложатся на React, а не на код прикладного разработчика.
Коварность useSyncExternalStore в требовании стабильного снапшота: если getSnapshot каждый раз возвращает новый объект, React увидит "изменение" на каждом рендере и уйдёт в бесконечный цикл. Значение обязано быть либо примитивом, либо закешированной ссылкой. Для собственного стора паттерн выглядит так:
const listeners = new Set<() => void>();
const viewport = { width: 0, height: 0 };
const notify = () => listeners.forEach((listener) => listener());
export const viewportStore = {
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot() {
return viewport;
},
update(size: { width: number; height: number }) {
Object.assign(viewport, size);
notify();
}
};
Ссылка на объект viewport одна и та же, мутируют её поля, а не заменяют объект целиком, поэтому циклов не возникает. Хук поверх такого стора занимает две строки и один раз пишется на всю команду.
Тестирование кастомных хуков функцией renderHook из Testing Library
Хук нельзя вызвать как функцию в тесте: вне компонента состояние не работает. Обходной путь - обёртка renderHook из пакета @testing-library/react версии 16.3.3, которая монтирует хук в невидимый компонент и отдаёт рукоятку result с текущим значением:
import { act, renderHook } from "@testing-library/react";
test("значение приходит после задержки", () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 400),
{ initialProps: { value: "па" } }
);
rerender({ value: "пальто" });
expect(result.current).toBe("па");
act(() => {
jest.advanceTimersByTime(400);
});
expect(result.current).toBe("пальто");
jest.useRealTimers();
});
Тест читается как сценарий: хук получил "па", значение сменилось на "пальто", но внутри задержки лежит старое, время промотано на 400 миллисекунд, и только теперь наружу вышло новое. Важно, что advanceTimers обёрнут в act: без него React не успеет отрисовать обновление, и assertion сработает на устаревшем значении.
Пакет требует React 18 или 19 - ровно те версии, для которых существуют современные хуки, поэтому старый проект с React 17 тестировать через renderHook не получится. Для хуков с сетевыми эффектами таймеры дополняются моками fetch, а строгий режим в тестах ловит потерянные отписки: каждый подписавшийся обязан отписаться, иначе сборщик мусора не отпустит компонент.
Новинки React 19 и что даёт компилятор авторам общих хуков
React 19.3.1 продолжает линейку, начатую релизом 19 в декабре 2024 года, и приносит хуки, уменьшающие бойлерплейт вокруг форм и асинхронных действий. Ключевой - useActionState: он принимает функцию-действие и начальное состояние, а возвращает кортеж из состояния, диспетчера и признака ожидания:
import { useActionState } from "react";
async function addToCart(prev: string, formData: FormData) {
const id = String(formData.get("id"));
await api.post("/cart/items", { id });
return `товар ${id} добавлен`;
}
function CartButton() {
const [message, action, pending] = useActionState(addToCart, "");
return (
<form action={action}>
<input type="hidden" name="id" value="42" />
<button disabled={pending}>
{pending ? "Добавляем..." : "В корзину"}
</button>
<p>{message}</p>
</form>
);
}
Рядом живут useOptimistic для мгновенного отклика интерфейса до ответа сервера и useFormStatus для вложенных кнопок формы. Отдельного упоминания заслуживает компилятор React: он анализирует код на этапе сборки и сам решает, что мемоизировать, поэтому ручные useMemo и useCallback в общих хуках теряют былую обязательность. Хук, написанный без ручной мемоизации, перестаёт быть медленным по умолчанию.
Практический вывод для команды прост: правила вызова хуков остаются законом, линтер остаётся обязательным, а библиотека общих хуков перестаёт быть набором копипаста и становится продуктом с тестами, версионированием и документацией. Через полгода в такой библиотеке набирается пара десятков функций, и новый разработчик за вечер собирает экран каталога из готовых блоков, вместо того чтобы изобретать отложенный поиск в четвёртый раз подряд.