У компонентній моделі 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, на клієнті компонент монтується з початковим станом, а ефект після монтування викликає примусовий повторний рендер, створюючи зсув макета (CLS) та попередження гідратації.Промисловим стандартом вирішення цих викликів є 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 warnings.
Реєстрація слухача зовнішнього джерела. Сповіщення React Fiber про мутації через notify з автоматичним відписуванням при unmount.
Мемоїзація методів actions та відкладене форматування стану через useDebugValue для інспектора компонентів React DevTools.
Чому перевірку обов'язкових інваріантів (наприклад, assertNonNullContext) слід виконувати синхронно в тілі хука під час рендерингу, а не відкладати в useEffect?
ResizeObserver, IntersectionObserver та медіа-запитів із гарантованим очищенням слухачів (Zero Memory Leaks).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 порівнюється за значенням через Object.is
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, string, number) повертає значення, яке в рантаймі порівнюється через Object.is(prev, next). Якби getSnapshot повертав новий об'єктний літерал без кешування, React потрапляв би в нескінченний цикл рендерингу (Infinite Loop).
Синхронна перевірка типу та передумов аргументів. При порушенні генерується виняток до монтування JSX.
Серверний знімок повертає детерміноване дефолтне значення, повністю виключаючи звернення до window у Node.js.
useSyncExternalStore синхронізує початковий клієнтський getSnapshot із серверним, гарантуючи відсутність CLS.
Реєстрація слухача подій. При настанні події викликається 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 })
);
// Стабільний метод зондування зв'язку з AbortController
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 призводить до подвійного рендерингу: клієнт монтується з початковим дефолтним значенням, після чого ефект одразу запускає примусовий повторний рендер. Це сповільнює TTI та спричиняє Hydration Mismatch у Next.js.
// ❌ АНТИПАТЕРН: Подвійний рендеринг та розбіжність гідратації
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
);
}Якщо кастомний хук повертає функції дій (наприклад, refetch(), reset(), update()), створені без useCallback, усі оптимізовані дочірні компоненти (React.memo), які отримують їх як props, будуть безперервно перемальовуватися.
// ❌ АНТИПАТЕРН: Посилання на функцію створюється наново на кожному рендері
export function useCounterBad() {
const [count, setCount] = useState(0);
// Нове посилання на 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]);
}