Хуки появились как ответ на боль переиспользования логики между компонентами и быстро стали основным инструментом фронтенда. В маленьком проекте они почти не требуют дисциплины: пара useState, один эффект, всё читается сверху вниз. Масштаб меняет картину. Двадцать экранов, общие данные, десятки кастомных хуков и команда из восьми человек превращают безобидные функции в запутанную сеть зависимостей, где один неверный массив зависимостей роняет половину интерфейса. Нюансы, которые в туториале выглядят мелочью, здесь определяют, будет ли код поддерживаемым через год.
Правила хуков закладывают фундамент предсказуеменного рендера
Базовое правило известно всем: хуки вызываются только на верхнем уровне компонента, без условий, циклов и вложенных функций. Причина менее очевидна, чем правило. React не хранит связь хука с именем переменной: он сопоставляет порядок вызовов с внутренними слотами состояния. Один пропущенный вызов в условной ветке сдвигает всю последующую цепочку, и состояние одного эффекта неожиданно оказывается в другом.
На практике нарушение редко выглядит ошибкой: разработчик оборачивает хук в условие на минуту, просто чтобы не ломать пустой экран, и приложение работает до первого чередования веток. Поэтому правило проверяется автоматикой, а не вниманием. Линтер плагина eslint-plugin-react-hooks ловит и условные вызовы, и неполные массивы зависимостей.
Минимальная конфигурация для проекта:
{
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}
В большой кодовой базе эти два правила окупаются сильнее любых код-ревью: они превращают целый класс ошибок в невозможный. Ослаблять их стоит только осознанно, с комментарием-обоснованием, и таких мест за год набирается единицы.
На ревью к этому добавляется короткая шпаргалка: эффект не может возвращать разные хуки в зависимости от ветки, имя кастомного хука начинается с use, а список зависимостей читается как контракт. Команда, которая проговаривает контракт вслух, через полгода перестаёт его нарушать даже без линтера, но линтер всё равно остаётся страховкой на случай усталости и спешки.
Локальное состояние растёт и требует редьюсера вместо россыпи useState
Начало любого компонента выглядит одинаково: три-четыре useState, каждый отвечает за своё поле. Проблема появляется, когда поля оказываются связанными. Форма с двадцатью полями, где смена страны обнуляет регион, а смена региона перезагружает список городов, превращается в паутину отдельных эффектов, каждый из которых знает кусочек правил.
useReducer решает проблему переносом правил в одно место. Состояние описывается одним объектом, переходы получают имена действий, а компонент остаётся тонким: он отправляет действия и рисует результат.
Пример редьюсера формы доставки:
const initial = {
country: "",
region: "",
cities: [],
status: "idle"
};
function deliveryReducer(state, action) {
switch (action.type) {
case "country/set":
return { ...state, country: action.payload, region: "", cities: [] };
case "region/set":
return { ...state, region: action.payload, status: "loading" };
case "cities/loaded":
return { ...state, cities: action.payload, status: "ready" };
default:
return state;
}
}
Переход country/set заодно сбрасывает зависимые поля. Правило о том, что при смене страны очищается регион, записано один раз, и найти его может любой член команды за минуту. Граница перехода на редьюсер проста: если между полями появляются зависимости и промежуточные статусы, россыпь useState уже не читается.
Эффекты с зависимостями держат синхронизацию с внешним миром
useEffect часто понимают как код после рендера, и это понимание подводит. Эффект является механизмом синхронизации компонента с чем-то внешним: сетью, таймером, подпиской, интерфейсом браузера. С этой точки зрения массив зависимостей перестаёт быть капризом линтера: он честно описывает, от каких значений зависит синхронизация.
Классическая ошибка асинхронного эффекта - гонка запросов. Пользователь быстро меняет фильтр, старый запрос отвечает последним, и на экране висят данные, не соответствующие выбранному фильтру. Лечение - функция очистки и отмена запроса.
function useCities(regionId) {
const [cities, setCities] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch("/api/cities?region=" + regionId, { signal: controller.signal })
.then((r) => r.json())
.then(setCities)
.catch((e) => {
if (e.name !== "AbortError") throw e;
});
return () => controller.abort();
}, [regionId]);
return cities;
}
При смене regionId очистка отменяет прежний запрос до старта нового. Результат последнего вызова всегда побеждает, и интерфейс не показывает чужие данные. Такая аккуратность стоит нескольких строк, но именно она отличает эффект, который работает в демо, от эффекта, который работает в продакшене.
Отдельный признак проблемного эффекта - частая смена зависимостей. Если массив обновляется при каждом рендере, синхронизация выполняется слишком часто, и стоит спросить, не пытается ли эффект делать чужую работу. Иногда правильное решение - разнести эффект на два: один отвечает за подписку, второй за пересчёт. Это уменьшает количество перезапусков и делает каждый эффект понятнее в изоляции.
Кастомные хуки выделяют логику из компонентов в переиспользуемые модули
Настоящая сила хуков не в готовом наборе из библиотеки, а в возможности собирать собственные. Кастомный хук - обычная функция с префиксом use, которая вызывает другие хуки и возвращает готовый результат. Она забирает у компонента знание о деталях: как устроен таймер, где хранится значение, как формируется запрос.
Признаки того, что логику пора выносить:
- один и тот же набор useState и useEffect повторяется в двух и более компонентах;
- компонент разросся за сотни строк, и половина кода не про разметку;
- логика требует тестов, а тестировать компонент из-за неё тяжело;
- одни и те же детали, например задержка ввода или подписка на размер окна, копируются руками.
Хук с задержкой ввода, который пригождается почти в каждом проекте:
import { useEffect, useState } from "react";
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Внутри хука действуют те же правила: вызовы только на верхнем уровне, зависимости перечислены честно. Разница в границе: компонент получает готовую абстракцию, а детали синхронизации таймера живут в одном месте. Хороший кастомный хук держит одну ответственность: useDebounce отвечает за задержку и ни про что больше.
Мемоизация экономит рендеры только при точных границах
Оптимизационные хуки легко превратить в бессмысленную привычку: обернуть всё подряд в useMemo и useCallback, чтобы ничего не пересчитывалось. На деле мемоизация имеет цену: сравнение зависимостей само стоит работы, и бездумное применение замедляет код, а не ускоряет.
Осмысленный подход начинается с границ. Компонент, обёрнутый в memo, перерисовывается только при изменении входных значений, но сравнение проходит по ссылкам. Если родитель при каждом рендере создаёт новый объект или новую функцию, memo бесполезен: ссылки разные, дети рисуются заново. Отсюда пара из useCallback для обработчиков и useMemo для объектов, которые уходят вниз.
const FilterPanel = memo(function FilterPanel({ filters, onChange }) {
return (
<div>
{filters.map((f) => (
<Filter key={f.id} item={f} onPick={onChange} />
))}
</div>
);
});
function Catalog() {
const [filters, setFilters] = useState(defaultFilters);
const handleChange = useCallback((next) => {
setFilters((prev) => ({ ...prev, ...next }));
}, []);
return <FilterPanel filters={filters} onChange={handleChange} />;
}
Стабильные ссылки плюс memo на дочернем компоненте убирают лишние рендеры списка, который не меняется при каждом изменении родителя. Мемоизировать стоит измеренно: сначала профилировщик и подсчёт лишних рендеров, потом точечные правки на границах, а не тотальная обёртка всего файла.
Профилировщик показывает лишние рендеры прямо в дереве компонентов, и первая проверка обычно находит два-три тяжёлых места вместо ожидаемой дюжины. Каждое изменение после замера оставляет в коде след: комментарий с числом рендеров до и после. Через год такие комментарии объясняют, почему оптимизация выглядит именно так, и удерживают от случайного отката.
Состояние сервера живёт отдельно от интерфейсного состояния
Данные с бэкенда - не то же самое, что состояние интерфейса. Серверное состояние живёт на другом конце провода: оно устаревает само по себе, требует повторных запросов, кэша и обработки гонок. Держать его в обычных useState - значит вручную воспроизводить целую прослойку: загрузка, ошибка, повтор, отмена устаревших ответов и инвалидация после изменения.
Библиотеки кэширования забирают эту работу целиком. Хук запроса возвращает данные и статус, кэш обновляется по ключам, а успешное изменение помечает затронутые ключи на перезагрузку.
import { useQuery } from "@tanstack/react-query";
function ProductCard({ id }) {
const { data, status, error } = useQuery({
queryKey: ["product", id],
queryFn: () => api.products.get(id),
staleTime: 60000
});
if (status === "pending") return <Skeleton />;
if (status === "error") return <ErrorNote message={error.message} />;
return <ProductView product={data} />;
}
Одинаковый запрос из двух компонентов сходится в один сетевой вызов, повторная навигация берёт данные из кэша, а после успешного изменения достаточно пометить ключ невалидным. Компоненты при этом остаются про интерфейс: рисуют то, что есть, и отправляют действия. В большом приложении именно это разделение сокращает код эффектов сильнее любых микронастроек.
Настройка свежести подбирается по типу данных: справочник городов живёт в кэше часами, остатки на складе устаревают за минуты, а персональные ленты не кэшируются вовсе. Ключ запроса строится из параметров, влияющих на ответ, и одинаковые ключи в разных компонентах автоматически дедуплицируются. Чем стабильнее ключи, тем меньше лишних запросов выполняет приложение.
Типизация и тестирование хуков защищают масштаб от деградации
Чем больше хуков в проекте, тем важнее их контракт. TypeScript описывает контракт явно: входные типы, тип результата, необязательные параметры со значениями по умолчанию. Дженерики позволяют хуку подстраиваться под тип данных вызывающего.
import { useEffect, useState } from "react";
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Тестируются хуки напрямую, без рендера всего компонента. Утилита renderHook поднимает хук в тестовом окружении, а act оборачивает обновления состояния, чтобы проверки видели актуальные значения.
import { act, renderHook } from "@testing-library/react";
test("useDebouncedValue отдаёт последнее значение", () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebouncedValue(value, 300),
{ initialProps: { value: "a" } }
);
rerender({ value: "ab" });
expect(result.current).toBe("a");
act(() => jest.advanceTimersByTime(300));
expect(result.current).toBe("ab");
});
Такие тесты быстрые, точные и не ломаются от смены вёрстки. Дисциплина простая: каждый кастомный хук имеет типы и хотя бы один тест на главное поведение. В приложении из сотни хуков это единственная страховка от тихой порчи контрактов. Хуки остаются обычными функциями, и масштаб не страшен там, где их границы описаны, а правила исполняет линтер.