В архитектуре современных React-приложений кастомные хуки служат основным инструментом декомпозиции и повторного использования логики: синхронизации с внешними хранилищами (Local Storage, IndexedDB), управления стейт-машинами, дебаунса/троттлинга, подписки на сетевые веб-сокеты и интеграции с серверными данными.
Правила хуков React (Rules of Hooks) запрещают вызывать хуки вне функциональных компонентов или других хуков. До появления специализированных утилит разработчикам приходилось вручную создавать синтетический компонент-пустышку (TestHostComponent), вызывать в нем тестируемый хук, пробрасывать возвращаемые значения через пропсы или сохранять в замыкание и рендерить разметку через render(). Это создавало избыточный шаблонный код (boilerplate) и загромождало тесты.
renderHook() стандартизирует тестирование кастомных хуков. Она изолирует бизнес-логику от визуального представления (UI), гарантируя соблюдение жизненного цикла React Fiber (монтирование, обновление пропсов, очистка эффектов).
При этом ключевой концепцией остается соблюдение границ act(). В React 19 планировщик Concurrent React агрегирует обновления в микротаски. Понимание того, когда act() вызывается автоматически библиотекой RTL, а когда требуется явная синхронизация, критично для устранения предупреждений «An update was not wrapped in act(...)».
Под капотом renderHook() динамически конструирует невидимый функциональный компонент, рендерит его через стандартный render() и подписывается на каждое обновление. Результат работы хука помещается в объект-обертку result, где поле result.current содержит самое актуальное значение, возвращенное хуком на последнем тике рендера.
+-----------------------------------------------------------------------+
| renderHook((props) => useCounter(props), options) |
+-----------------------------------------------------------------------+
│
▼ (Создание фиктивного хоста)
+-----------------------------------------------------------------------+
| Synthetic Host Component <TestComponent {...initialProps} /> |
| - Опциональный Context Wrapper (QueryClient, AuthContext) |
+-----------------------------------------------------------------------+
│
▼ (Фаза рендеринга React Fiber)
+-----------------------------------------------------------------------+
| Вызов хука: const data = useCounter(props); |
| result.current = data; |
+-----------------------------------------------------------------------+
│
┌─────────────────┴─────────────────┐
▼ ▼
+-----------------------------------+ +---------------------------------+
| rerender({ step: 5 }) | | unmount() |
| - Повторный рендер с новыми props | | - Демонтирование хоста |
| - Обновление result.current | | - Вызов деструкторов useEffect |
+-----------------------------------+ +---------------------------------+renderHook#result.current: Значение (примитив, кортеж или объект), возвращенное кастомным хуком. Доступ к нему должен осуществляться непосредственно в момент проверки, чтобы избежать захвата устаревшего замыкания (stale closure).rerender(newProps?): Принудительно запускает повторный рендеринг хост-компонента, передавая обновленные параметры в хук.unmount(): Размонтирует хост-компонент, инициируя фазу очистки всех активных эффектов useEffect и useLayoutEffect.Настройка initialProps и создание провайдеров-оберток (wrapper) при необходимости.
Вызов renderHook() для запуска фазы рендеринга невидимого хост-компонента.
Синхронная сверка начальных значений через result.current без деструктуризации.
Вызов функций хука внутри act() или передача новых пропсов через rerender(newProps).
Ожидание изменений через waitFor() и проверка очистки эффектов при unmount().
Почему при тестировании кастомного хука через renderHook() категорически не рекомендуется деструктурировать возвращаемые значения сразу (const { count } = result.current)?
renderHook#import { renderHook, waitFor, act } from '@testing-library/react';
const { result, rerender, unmount } = renderHook(
(renderCallbackProps) => useMyHook(renderCallbackProps),
{
initialProps: { count: 0 },
wrapper: ({ children }) => <MyProvider>{children}</MyProvider>,
}
);act() нужен, а когда нет?#В 95% случаев в RTL вызов act() не требуется:
userEvent.* (click, type, tab) уже обернуты в act() под капотом.waitFor(() => expect(...)) автоматически оборачивает каждую итерацию проверки в act().renderHook() и rerender() по умолчанию выполняются внутри act().Явный act() необходим ТОЛЬКО когда:
Стейт хука мутирует в результате внешнего синхронного или асинхронного события, инициированного вручную без использования userEvent или waitFor (например, прямой вызов экспортированной функции хука result.current.increment(), либо срабатывание таймера/события сокета).
// ✅ Прямой вызов функции хука оборачивается в act()
act(() => {
result.current.increment();
});useDebounce) с rerender и Fake Timers#Тестирование хуков задержки требует проверки неизменности значения до истечения таймера, обновления после срабатывания таймера и корректного сброса предыдущего таймера при частой смене аргументов.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(timer);
};
}, [value, delayMs]);
return debouncedValue;
}
describe('useDebounce Hook', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('задерживает обновление значения на указанный интервал времени', () => {
interface HookProps {
readonly value: string;
readonly delay: number;
}
const { result, rerender } = renderHook(
({ value, delay }: HookProps) => useDebounce(value, delay),
{
initialProps: { value: 'initial', delay: 300 },
}
);
// Начальное состояние синхронно совпадает со стартовым
expect(result.current).toBe('initial');
// Передаем новое значение через rerender
rerender({ value: 'updated-1', delay: 300 });
// До истечения времени значение НЕ должно меняться
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('initial');
// Быстро меняем значение еще раз (проверка сброса предыдущего таймера)
rerender({ value: 'updated-2', delay: 300 });
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('initial');
// Продвигаем время до завершения последнего таймера
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('updated-2');
});
});useLocalStorage)#Хук должен сохранять значение в window.localStorage, реагировать на внешние события storage из других вкладок и корректно освобождать память при размонтировании.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage<T>(key: string, initialValue: T): readonly [T, (val: T | ((prev: T) => T)) => void] {
const readValue = useCallback((): T => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error: unknown) {
console.error(error);
}
},
[key, storedValue]
);
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue) as T);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue] as const;
}
describe('useLocalStorage Hook', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('читает дефолтное значение и обновляет localStorage при вызове сеттера', () => {
const { result } = renderHook(() => useLocalStorage<number>('app_theme_mode', 1));
expect(result.current[0]).toBe(1);
act(() => {
result.current[1](2);
});
expect(result.current[0]).toBe(2);
expect(window.localStorage.getItem('app_theme_mode')).toBe('2');
});
it('синхронизируется при получении внешнего StorageEvent', () => {
const { result, unmount } = renderHook(() =>
useLocalStorage<string>('auth_token', 'empty')
);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: 'auth_token',
newValue: JSON.stringify('jwt_secret_token_2026'),
})
);
});
expect(result.current[0]).toBe('jwt_secret_token_2026');
// Проверка очистки слушателя при unmount
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('storage', expect.any(Function));
});
});wrapper)#Кастомные хуки часто обращаются к контекстам авторизации, темы или состояния запросов. Параметр wrapper позволяет обернуть невидимый хост-компонент в реальный или мокированный Context Provider.
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import React, { createContext, useContext, useMemo } from 'react';
interface UserProfile {
readonly id: string;
readonly role: 'admin' | 'viewer';
}
interface AuthContextType {
readonly user: UserProfile | null;
readonly isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
function useUserPermissions() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useUserPermissions must be used within an AuthProvider');
}
const canEdit = context.isAuthenticated && context.user?.role === 'admin';
return { ...context, canEdit };
}
describe('useUserPermissions with Context Wrapper', () => {
it('выбрасывает ошибку, если хук вызван вне провайдера', () => {
// Подавляем консольную ошибку React для ожидаемого исключения
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => renderHook(() => useUserPermissions())).toThrow(
'useUserPermissions must be used within an AuthProvider'
);
spy.mockRestore();
});
it('возвращает права администратора при авторизованном админе', () => {
const mockAuthValue: AuthContextType = {
user: { id: 'usr_77', role: 'admin' },
isAuthenticated: true,
};
// Обертка провайдера
const wrapper = ({ children }: { readonly children: React.ReactNode }) => (
<AuthContext.Provider value={mockAuthValue}>
{children}
</AuthContext.Provider>
);
const { result } = renderHook(() => useUserPermissions(), { wrapper });
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.canEdit).toBe(true);
expect(result.current.user?.role).toBe('admin');
});
});waitFor#Для хуков, выполняющих асинхронные вычисления, сетевые запросы или подписку на таймеры, используется утилита waitFor. Она циклически опрашивает утверждение до тех пор, пока оно не станет истинным.
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useState, useEffect } from 'react';
interface AsyncState<T> {
readonly data: T | null;
readonly isLoading: boolean;
readonly error: string | null;
}
function useAsyncData<T>(fetcher: () => Promise<T>): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({
data: null,
isLoading: true,
error: null,
});
useEffect(() => {
let isCancelled = false;
fetcher()
.then((res) => {
if (!isCancelled) {
setState({ data: res, isLoading: false, error: null });
}
})
.catch((err: unknown) => {
if (!isCancelled) {
const message = err instanceof Error ? err.message : 'Unknown error';
setState({ data: null, isLoading: false, error: message });
}
});
return () => {
isCancelled = true;
};
}, [fetcher]);
return state;
}
describe('useAsyncData Hook', () => {
it('переключает флаг isLoading и сохраняет полученные данные', async () => {
const mockFetcher = () => Promise.resolve({ serverStatus: 'HEALTHY_2026' });
const { result } = renderHook(() => useAsyncData(mockFetcher));
// Начальное состояние: загрузка активна
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeNull();
// Асинхронное ожидание разрешения промиса
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual({ serverStatus: 'HEALTHY_2026' });
expect(result.current.error).toBeNull();
});
});Как по каноническому контракту TDD протестировать, что кастомный хук корректно снимает глобальные слушатели событий и таймеры при размонтировании компонента?
Если деструктурировать поля из result.current сразу после вызова renderHook, переменные сохранят ссылки на начальные примитивы. Последующие обновления стейта изменят result.current, но ваши локальные переменные останутся старыми.
// ❌ АНТИПАТТЕРН: Деструктуризация на старте приводит к захвату устаревшего стейта
const { result } = renderHook(() => useCounter());
const { count, increment } = result.current;
act(() => increment());
expect(count).toBe(1); // ПАДЕНИЕ: count равен 0!
// ✅ РЕШЕНИЕ: Всегда обращаться к свойствам через result.current напрямую
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);Попытка написать await act(async () => { await waitFor(...) }) вызывает дублирование контекста планировщика, так как waitFor внутри уже обернут в act. Это приводит к скрытым ошибкам синхронизации микротасок и ворнингам в консоли.
// ❌ АНТИПАТТЕРН: Избыточный вызов act вокруг waitFor
await act(async () => {
await waitFor(() => expect(result.current.ready).toBe(true));
});
// ✅ РЕШЕНИЕ: waitFor вызывается напрямую верхнеуровнево
await waitFor(() => expect(result.current.ready).toBe(true));Если хук запускает таймеры, слушает глобальные события окна или открывает соединение сокета, отсутствие вызова unmount() в тесте делает невозможным проверку деструкторов эффекта (return () => clearInterval(...)).
// ❌ АНТИПАТТЕРН: Тест завершился, утечка обработчиков не проверена
const { result } = renderHook(() => useWindowScroll());
// ✅ РЕШЕНИЕ: Явный вызов unmount и верификация снятия подписки
const { result, unmount } = renderHook(() => useWindowScroll());
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));Повторный вызов renderHook монтирует абсолютно новое изолированное дерево компонентов со сброшенным начальным стейтом. Для передачи новых аргументов в существующий экземпляр хука необходимо использовать метод rerender().
// ❌ АНТИПАТТЕРН: Создает два несвязанных хука
renderHook(() => useMultiplier(2));
renderHook(() => useMultiplier(4)); // Стейт первого хука уничтожен
// ✅ РЕШЕНИЕ: Использование initialProps и rerender
const { result, rerender } = renderHook(
(val) => useMultiplier(val),
{ initialProps: 2 }
);
rerender(4);