В компонентной модели React кастомный хук представляет собой механизм инкапсуляции состояния и сайд-эффектов на базе композиции стандартных примитивов React Fiber. В высоконагруженных enterprise-системах кастомные хуки формируют сервисный слой интерфейса: они абстрагируют работу с браузерными Web API, сетевыми протоколами, локальными хранилищами и сложными конечными автоматами.
В современном React 19 и Next.js App Router кастомный хук рассматривается как изолированный программный интерфейс (API). Проектирование хуков требует перехода от хрупких позиционных кортежей [state, setState] к объектам конфигурации и результата (Options / Result Pattern), строгой валидации инвариантов на этапе рендеринга и изоморфной синхронизации внешних источников через useSyncExternalStore.
Разработка хуков без формализованной контрактной архитектуры приводит к трем критическим проблемам в масштабируемых кодовых базах:
[data, loading, error, refetch, mutate] добавление нового флага в середину массива ломает код сотен компонентов-потребителей. Перепутать местами однотипные переменные (например, два булевых флага) при деструктуризации — частая причина скрытых регрессий.window.innerWidth, localStorage, matchMedia) через связку useState + useEffect гарантирует двухфазный рендеринг: на сервере генерируется один HTML, на клиенте компонент монтируется с пустым состоянием, а затем эффект принудительно вызывает повторный рендер, вызывая раздражающий Layout Shift и ошибки рассинхронизации гидратации.Решением выступает Headless Hook Architecture в связке со стандартом useSyncExternalStore.
Архитектурный контракт хука строится на трех фундаментальных столпах:
Readonly-полями, дефолтными значениями и дискриминированными объединениями для взаимоисключающих сценариев.status, data), группу ссылочно-стабильных методов управления (actions) и служебные метаданные.// ❌ АНТИПАТТЕРН: Наивный позиционный кортеж
const [data, loading, error, refetch, mutate] = useResource(id, true, 5000, 'cache-first');
// Риски: жесткий порядок аргументов, невозможно пропустить параметр, опечатки в типах
// ✅ ПРАВИЛЬНО: Строгий контракт Options / Result
const { data, status, actions } = useResource({
id,
pollingIntervalMs: 5000,
fetchPolicy: 'cache-first',
enabled: isAuthorized,
});
// Преимущества: автодокументирование, расширяемость без breaking changes, чистая деструктуризацияПроверка предусловий аргументов до вызова реактивных примитивов. При нарушении контракта выбрасывается TypeError с понятным сообщением.
Формирование функции getServerSnapshot. Гарантируется отсутствие обращений к window, document и navigator в среде Node.js/Edge.
useSyncExternalStore синхронно сопоставляет результат getSnapshot с серверным слепком, исключая мерцание интерфейса и Hydration Warning.
Регистрация слушателя внешнего источника. Оповещение React Fiber о мутациях через функцию notify с автоматической отпиской при unmount.
Мемоизация группы actions, отложенное форматирование состояния через useDebugValue для инспектора компонентов React DevTools.
Почему в промышленной архитектуре кастомных хуков проверку обязательных инвариантов (например, assertNonNullContext) следует выполнять синхронно в теле хука во время рендера, а не откладывать в useEffect?
ResizeObserver, IntersectionObserver и медиа-запросов с гарантированной очисткой слушателей (Zero Memory Leaks).Combobox), модальных окон, виртуализированных таблиц, где хук управляет фокусом и клавиатурой, а разметка остается полностью кастомизируемой.useSyncExternalStore без каскадных ререндеров и разрывов гидратации.При проектировании входных опций хука интерфейс объявляется с модификатором readonly. Для предотвращения тихих сбоев в рантайме создаются предикаты валидации инвариантов.
Рассмотрим эталонную реализацию хука медиа-запросов useMediaQuery:
import { useSyncExternalStore, useDebugValue, useMemo } from 'react';
export interface UseMediaQueryOptions {
/** Флаг соответствия по умолчанию для среды SSR */
readonly defaultMatches?: boolean;
/** Пользовательский перехватчик ошибок некорректного синтаксиса CSS */
readonly onError?: (error: Error) => void;
}
export interface UseMediaQueryResult {
/** Текущий флаг соответствия viewport медиа-запросу */
readonly matches: boolean;
/** Валидированная строка исходного CSS медиа-запроса */
readonly query: string;
}
/**
* Валидирует инвариант синтаксической корректности CSS Media Query.
* Защищает от передачи undefined, пустых строк и тихих сбоев.
*/
function assertValidMediaQuery(query: string): void {
if (typeof query !== 'string' || query.trim().length === 0) {
throw new TypeError(
`[useMediaQuery] Нарушение инварианта: аргумент 'query' обязан быть непустой строкой. Получено: ${JSON.stringify(query)}`
);
}
}
export function useMediaQuery(
query: string,
options: UseMediaQueryOptions = {}
): UseMediaQueryResult {
// 1. Проверка инварианта на самом раннем этапе рендера (Fail-Fast)
assertValidMediaQuery(query);
const { defaultMatches = false, onError } = options;
// 2. Изоляция функций подписки и снимков
const { subscribe, getSnapshot, getServerSnapshot } = useMemo(() => {
return {
subscribe: (onStoreChange: () => void): (() => void) => {
if (typeof window === 'undefined' || !('matchMedia' in window)) {
return () => {};
}
let mediaQueryList: MediaQueryList;
try {
mediaQueryList = window.matchMedia(query);
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
onError?.(error);
return () => {};
}
mediaQueryList.addEventListener('change', onStoreChange);
return () => {
mediaQueryList.removeEventListener('change', onStoreChange);
};
},
// Примитивное boolean-значение стабильно по определению (сравнение по значению)
getSnapshot: (): boolean => {
if (typeof window === 'undefined' || !('matchMedia' in window)) {
return defaultMatches;
}
return window.matchMedia(query).matches;
},
// Детерминированный серверный снимок: исключает обращение к DOM/BOM
getServerSnapshot: (): boolean => defaultMatches,
};
}, [query, defaultMatches, onError]);
// 3. Подключение к механизму изоморфной синхронизации React Fiber
const matches = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);
// 4. Отладочный след для React DevTools с отложенным форматированием
useDebugValue({ query, matches }, (val) =>
`[${val.query}] => ${val.matches ? 'MATCHED' : 'UNMATCHED'}`
);
return { matches, query };
}Обратите внимание: функция getSnapshot для примитивов (boolean, number, string) возвращает значение, сравниваемое в рантайме по алгоритму Object.is(prev, next). Если бы getSnapshot возвращал новый объектный литерал без кеширования, React входил бы в бесконечный цикл рендеринга (Infinite Loop).
Синхронная проверка типа и предусловий аргументов. При нарушении контракта генерируется типизированное исключение до монтирования JSX.
Генератор серверного слепка возвращает детерминированное дефолтное состояние, исключая вызовы window и navigator в среде Node.js.
useSyncExternalStore сверяет серверный слепок со слепком getSnapshot(). Синхронизация исключает расхождения разметки.
Регистрация слушателя событий. При наступлении системного события вызывается onStoreChange, планирующий рендер в очереди Lanes.
Монолитный кастомный хук, выполняющий сетевой запрос, запись в localStorage, валидацию форм и перехват горячих клавиш, нарушает Single Responsibility Principle. Промышленный подход основан на многоуровневой композиции:
[useKeySequence] (Атомарный: прослушивает KeyboardEvent и накапливает буфер ввода)
│
▼
[useShortcut] (Композиционный: сопоставляет последовательность с картой команд)
│
▼
[useCommandPalette] (Headless UI: управляет состоянием диалога, фокусом и поиском)Каждый промежуточный хук оперирует своим контрактом, а потребитель верхнего уровня объединяет их в единый интерфейс.
Инженер создал хук useWindowDimensions, где getSnapshot возвращает объект: () => ({ width: window.innerWidth, height: window.innerHeight }). Почему приложение зависает или выдает ошибку Maximum update depth exceeded?
Ниже представлен эталонный промышленный хук для мониторинга интернет-соединения, качества связи (Network Information API), активного зондирования сервера (Health Check) и синхронизации с сервером.
Хук полностью устойчив к SSR, использует шаблон Singleton Store для дедупликации слушателей между десятками компонентов и предоставляет отладочный след в DevTools.
import { useSyncExternalStore, useDebugValue, useCallback, useMemo } from 'react';
export type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g' | 'unknown';
export interface NetworkSnapshot {
readonly isOnline: boolean;
readonly downlinkMbps: number | null;
readonly rttMs: number | null;
readonly effectiveType: EffectiveConnectionType;
readonly lastChangedAt: number;
}
export interface UseNetworkStatusOptions {
/** URL эндпоинта для активного зондирования доступности бэкенда */
readonly pingUrl?: string;
/** Таймаут проверки соединения в миллисекундах */
readonly pingTimeoutMs?: number;
/** Начальное состояние для серверного рендеринга (SSR) */
readonly fallbackSnapshot?: Partial<NetworkSnapshot>;
}
export interface UseNetworkStatusActions {
/** Принудительная проверка реальной связности с сервером */
readonly checkConnection: () => Promise<boolean>;
}
export interface UseNetworkStatusResult {
readonly status: NetworkSnapshot;
readonly actions: UseNetworkStatusActions;
}
// Защищенный детерминированный слепок для среды SSR
const DEFAULT_SERVER_SNAPSHOT: NetworkSnapshot = Object.freeze({
isOnline: true,
downlinkMbps: null,
rttMs: null,
effectiveType: 'unknown',
lastChangedAt: 0,
});
interface NetworkInformationNavigator extends Navigator {
connection?: {
downlink?: number;
rtt?: number;
effectiveType?: EffectiveConnectionType;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
};
}
/**
* Синглтон-хранилище сетевого статуса.
* Гарантирует единственный набор глобальных слушателей событий окна для всех компонентов.
*/
class NetworkStore {
private listeners = new Set<() => void>();
private currentSnapshot: NetworkSnapshot;
constructor(fallback?: Partial<NetworkSnapshot>) {
this.currentSnapshot = this.readClientSnapshot(fallback);
if (typeof window !== 'undefined') {
window.addEventListener('online', this.handleNetworkChange);
window.addEventListener('offline', this.handleNetworkChange);
const nav = navigator as NetworkInformationNavigator;
if (nav.connection?.addEventListener) {
nav.connection.addEventListener('change', this.handleNetworkChange);
}
}
}
private readClientSnapshot(fallback?: Partial<NetworkSnapshot>): NetworkSnapshot {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return { ...DEFAULT_SERVER_SNAPSHOT, ...fallback };
}
const nav = navigator as NetworkInformationNavigator;
const connection = nav.connection;
return {
isOnline: navigator.onLine ?? true,
downlinkMbps: typeof connection?.downlink === 'number' ? connection.downlink : null,
rttMs: typeof connection?.rtt === 'number' ? connection.rtt : null,
effectiveType: connection?.effectiveType ?? 'unknown',
lastChangedAt: Date.now(),
};
}
private handleNetworkChange = (): void => {
this.currentSnapshot = this.readClientSnapshot();
this.listeners.forEach((listener) => listener());
};
public subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
public getSnapshot = (): NetworkSnapshot => {
// Возвращаем стабильную ссылку на объект; ссылка обновляется только при реальном событии
return this.currentSnapshot;
};
}
let globalNetworkStore: NetworkStore | null = null;
function getGlobalNetworkStore(fallback?: Partial<NetworkSnapshot>): NetworkStore {
if (!globalNetworkStore) {
globalNetworkStore = new NetworkStore(fallback);
}
return globalNetworkStore;
}
export function useNetworkStatus(
options: UseNetworkStatusOptions = {}
): UseNetworkStatusResult {
const {
pingUrl = '/api/health',
pingTimeoutMs = 3000,
fallbackSnapshot,
} = options;
const store = useMemo(
() => getGlobalNetworkStore(fallbackSnapshot),
[fallbackSnapshot]
);
// Подписка на внешнее хранилище без риска Tearing и Hydration Mismatch
const status = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
() => ({ ...DEFAULT_SERVER_SNAPSHOT, ...fallbackSnapshot })
);
// Стабильный метод активного зондирования с прерыванием по таймауту
const checkConnection = useCallback(async (): Promise<boolean> => {
if (typeof window === 'undefined') return true;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), pingTimeoutMs);
try {
const response = await fetch(pingUrl, {
method: 'HEAD',
cache: 'no-store',
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
window.clearTimeout(timeoutId);
}
}, [pingUrl, pingTimeoutMs]);
const actions = useMemo<UseNetworkStatusActions>(
() => ({
checkConnection,
}),
[checkConnection]
);
// useDebugValue отображает статус в инспекторе компонентов React DevTools
useDebugValue(status, (s) =>
`Online: ${s.isOnline ? 'YES' : 'NO'} (${s.effectiveType}, RTT: ${s.rttMs ?? 'n/a'}ms)`
);
return { status, actions };
}Запись данных из глобальных объектов window или navigator в локальный стейт через useEffect приводит к тому, что первичный рендер клиента генерирует одно дерево, а эффект сразу вызывает принудительный повторный рендер. Это не только замедляет Time to Interactive (TTI), но и порождает ошибки Hydration Mismatch в Next.js.
// ❌ АНТИПАТТЕРН: Двойной рендеринг и Hydration Mismatch
export function useWindowWidthBad() {
const [width, setWidth] = useState<number>(0);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
setWidth(window.innerWidth); // Вызывает принудительный ререндер после монтирования
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
// ✅ ПРАВИЛЬНО: useSyncExternalStore с синхронным изоморфным слепком
export function useWindowWidthGood(defaultWidth = 1280) {
return useSyncExternalStore(
(notify) => {
window.addEventListener('resize', notify);
return () => window.removeEventListener('resize', notify);
},
() => window.innerWidth,
() => defaultWidth
);
}Если кастомный хук возвращает функции (например, update(), refetch(), reset()), создаваемые заново при каждом рендере без мемоизации, все мемоизированные дочерние компоненты (React.memo), принимающие их в качестве props, будут непрерывно ререндериться.
// ❌ АНТИПАТТЕРН: Функция actions пересоздается на каждый тик рендера
export function useCounterBad() {
const [count, setCount] = useState(0);
// При каждом изменении count создается новая ссылка на increment
const increment = () => setCount((c) => c + 1);
return { count, increment };
}
// ✅ ПРАВИЛЬНО: Стабильные действия через useCallback
export function useCounterGood() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount((c) => c + 1);
}, []);
return { count, increment };
}Если хук выполняет асинхронные запросы в ответ на смену пропсов, ранние медленные ответы могут завершиться позже свежих быстрых ответов, перезаписав актуальные данные устаревшими.
// ❌ АНТИПАТТЕРН: Медленный запрос с id=1 может перезаписать свежий ответ с id=2
export function useUserDataBad(userId: string) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then((data) => setUser(data));
}, [userId]);
return user;
}
// ✅ ПРАВИЛЬНО: Отмена устаревшего запроса через AbortController
export function useUserDataGood(userId: string) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = (await response.json()) as User;
setUser(data);
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
console.error('Fetch failed', err);
}
}
void load();
return () => {
controller.abort();
};
}, [userId]);
return user;
}Попытка вызывать хуки внутри условий (if, тернарных выражений или циклов) разрушает целостность односвязного списка хуков в узле React Fiber, приводя к фатальному сбою рантайма: Rendered fewer hooks than expected.
// ❌ АНТИПАТТЕРН: Условный вызов хука разрушает связный список Fiber
export function useConditionalLogger(shouldLog: boolean, data: string) {
if (shouldLog) {
useEffect(() => { // ФАТАЛЬНЫЙ СБОЙ: разная длина связного списка хуков между рендерами!
console.log(data);
}, [data]);
}
}
// ✅ ПРАВИЛЬНО: Хук вызывается безусловно, условие проверяется внутри эффекта
export function useConditionalLoggerGood(shouldLog: boolean, data: string) {
useEffect(() => {
if (!shouldLog) return;
console.log(data);
}, [shouldLog, data]);
}