В архітектурі сучасних 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.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 } 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();
});
});Якщо деструктурувати поля з 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);Налаштування initialProps та створення провайдерів-обгорток (wrapper) за необхідності.
Виклик renderHook() для запуску фази рендерингу невидимого хост-компонента.
Синхронне звіряння початкових значень через result.current без деструктуризації.
Виклик функцій хука всередині act() або передача нових пропсів через rerender(newProps).
Очікування змін через waitFor() та перевірка очищення ефектів при unmount().