Продуктовая команда растёт, и вместе с ней расползаются повторяющиеся куски логики: поиск с задержкой скопирован в четыре файла, работа с локальным хранилищем написана тремя людьми по-разному, а проверка сети вообще живёт в виде глобальной переменной. Копипаст расходится, правка одного экземпляра не доезжает до остальных, и один баг чинится трижды. Кастомный хук решает проблему штатными средствами React: функция с префиксом use собирает из стандартных хуков готовый блок поведения, который команда держит в общей библиотеке и вызывает из любого компонента. В статье пройден путь от правил вызова хуков до готового набора: отложенное значение, запросы с отменой, подписка на внешний стор, тесты и новинки React 19.3.1.

Правила хуков которые удерживают общую логику предсказуемой

Кастомный хук устроен обманчиво просто: обычная функция, внутри которой вызваны штатные useState, useEffect или чужие кастомные хуки. Ограничения диктует не синтаксис, а механика React: хуки привязываются к компоненту в порядке вызова, и порядок этот обязан быть одинаковым при каждом рендере. Отсюда три жёстких правила:

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

Практический вывод для команды прост: правила вызова хуков остаются законом, линтер остаётся обязательным, а библиотека общих хуков перестаёт быть набором копипаста и становится продуктом с тестами, версионированием и документацией. Через полгода в такой библиотеке набирается пара десятков функций, и новый разработчик за вечер собирает экран каталога из готовых блоков, вместо того чтобы изобретать отложенный поиск в четвёртый раз подряд.