Хуки появились как ответ на боль переиспользования логики между компонентами и быстро стали основным инструментом фронтенда. В маленьком проекте они почти не требуют дисциплины: пара 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, которая вызывает другие хуки и возвращает готовый результат. Она забирает у компонента знание о деталях: как устроен таймер, где хранится значение, как формируется запрос.

Признаки того, что логику пора выносить:

  1. один и тот же набор useState и useEffect повторяется в двух и более компонентах;
  2. компонент разросся за сотни строк, и половина кода не про разметку;
  3. логика требует тестов, а тестировать компонент из-за неё тяжело;
  4. одни и те же детали, например задержка ввода или подписка на размер окна, копируются руками.

Хук с задержкой ввода, который пригождается почти в каждом проекте:

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");
});

Такие тесты быстрые, точные и не ломаются от смены вёрстки. Дисциплина простая: каждый кастомный хук имеет типы и хотя бы один тест на главное поведение. В приложении из сотни хуков это единственная страховка от тихой порчи контрактов. Хуки остаются обычными функциями, и масштаб не страшен там, где их границы описаны, а правила исполняет линтер.