У декларативній моделі React 19 компонент проєктується як чиста функція від пропсів і стану. Проте під час інтеграції з імперативним оточенням браузера (таймери setInterval, мережеві сокети, підписки на глобальні події window, несинхронізовані ланцюжки Promise) виникають приховані дефекти, здатні спричинити аварійне завершення клієнтської вкладки.
У корпоративних SPA з тривалим часом користувацької сесії (CRM-системи, банківські термінали, аналітичні панелі) недбале керування сайд-ефектами призводить до трьох критичних проблем:
Для усунення цих уразливостей React 19 вимагає суворої дисципліни очищення (Cleanup Phase), використання AbortController та коректного профілювання купи пам'яті (Heap Snapshot).
Типова анатомія стану гонитви та витоку ресурсів:
Рендер #1 (Запит A, ID=10) ──────────► [Мережевий запит A (повільний, 1200 мс)]
│ │
▼ ▼
Користувач клікає Tab 2 │
│ │
▼ │
Рендер #2 (Запит B, ID=11) ──► [Запит B (швидкий, 150 мс)]
│ │
▼ │
UI оновлено даними B (ID=11) │
▼
Запит A нарешті завершився!
stateUpdate(A) перезаписує UI!
──► [БАГ: UI відображає застарілий ID=10!]Якщо ж компонент було взагалі відмонтовано до відповіді Запиту A:
[Відмонтований Компонент] ◄── [Замикання всередині Promise.then()] ◄── [Macrotask]
│
└── Не може бути видалений збирачем сміття V8 (Detached DOM Element)!Відкрийте вкладку Memory в Chrome DevTools, запустіть збирач сміття (іконка кошика) та зробіть Heap snapshot 1.
Відкрийте підозрілий модальний діалог або екран, виконайте цільові дії та повністю закрийте його 5–10 разів.
Знову викличте збирання сміття для очищення тимчасових об'єктів та зафіксуйте Heap snapshot 2.
Оберіть фільтр 'Objects allocated between Snapshot 1 and 2'. Відфільтруйте за назвою компонента або Detached HTMLElement.
У нижній панелі Retainers знайдіть корінь витоку: завислий слухач window, setInterval або замикання промісу.
Код кнопки лічильника: const [count, setCount] = useState(0); const inc = useCallback(() => { setCount(count + 1); }, []);. Чому при багаторазових кліках по кнопці значення лічильника не піднімається вище 1?
AbortController графіки показують перемішані неконсистентні дані.setInterval накопичує фонові таймери, що призводить до зависання браузера.Ініціалізація нового екземпляра AbortController() та отримання його AbortSignal.
Відправка HTTP-запиту з прив'язкою сигналу скасування до браузерного сокета.
Користувач змінює ID сутності або залишає сторінку. React викликає cleanup-функцію.
Браузер скидає мережевий стек (DOMException: AbortError), звільняючи пам'ять V8.
React вимагає, щоб тіло компонента було ідемпотентним (чистою функцією). Будь-яка пряма мутація пропсів або глобальних структур під час рендеру ламає алгоритм узгодження (Reconciliation):
// ❌ Грубий антипатерн: мутація пропса під час обчислення JSX
export function UserList({ users }: { users: string[] }) {
// sort() мутує вихідний масив пропсів на місці! Ломає мемоїзацію батька!
const sortedUsers = users.sort();
return (
<ul>
{sortedUsers.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
);
}
// ✅ Правильне рішення: імутабельна копія або useMemo
export function UserListFixed({ users }: { users: string[] }) {
const sortedUsers = useMemo(() => {
return [...users].sort();
}, [users]);
return (
<ul>
{sortedUsers.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
);
}У режимі <React.StrictMode> React навмисно викликає рендер кожного компонента двічі (Double Render), щоб миттєво підсвітити мутації та сайд-ефекти.
Чому популярний патерн із прапорцем isMounted (let isMounted = true; ... if (isMounted) setState(...); return () => { isMounted = false; }) вважається поганою інженерною практикою порівняно з AbortController?
Нижче наведено production-хук useSafeAsync, що реалізує строгу типізацію, гарантоване скасування мережевих запитів через AbortController, захист від стану гонитви та коректне очищення ресурсів:
import { useState, useEffect, useRef, type DependencyList } from 'react';
export interface AsyncState<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
}
export type AsyncFn<T> = (signal: AbortSignal) => Promise<T>;
export function useSafeAsync<T>(
asyncFn: AsyncFn<T>,
deps: DependencyList
): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({
data: null,
error: null,
isLoading: true,
});
// Зберігаємо лічильник запитів для гарантованого захисту від Race Conditions
const requestCounterRef = useRef<number>(0);
useEffect(() => {
const currentRequestId = ++requestCounterRef.current;
const controller = new AbortController();
// Переводимо хук у стан завантаження
setState((prev) => ({ ...prev, isLoading: true, error: null }));
async function execute() {
try {
const result = await asyncFn(controller.signal);
// Якщо це все ще найактуальніший запит і компонент не змінив залежності
if (currentRequestId === requestCounterRef.current) {
setState({
data: result,
error: null,
isLoading: false,
});
}
} catch (err: unknown) {
// Ігноруємо штатні помилки скасування запиту (AbortError)
if (err instanceof Error && err.name === 'AbortError') {
return;
}
if (currentRequestId === requestCounterRef.current) {
const formattedError =
err instanceof Error ? err : new Error('Невідома помилка завантаження даних');
setState({
data: null,
error: formattedError,
isLoading: false,
});
}
}
}
void execute();
// Cleanup-функція: скидаємо мережевий запит при розмонтуванні або зміні deps
return () => {
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}Підписка на події вікна (window.addEventListener('resize', ...)) або запуск setInterval без повернення функції очищення призводить до витоку пам'яті та багаторазового дублювання колбеків.
// ❌ Антипатерн: при кожному монтуванні створюється новий слухач, який не видаляється
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []); // Витік пам'яті під час розмонтування компонента!
// ✅ Правильне рішення: явне очищення в return або використання AbortSignal
useEffect(() => {
const controller = new AbortController();
// Браузери нативно підтримують опцію signal в addEventListener!
window.addEventListener('resize', handleResize, { signal: controller.signal });
return () => {
controller.abort(); // Автоматично видаляє слухач із пам'яті
};
}, []);Спроба запобігти оновленню стейту відмонтованого компонента через if (isMounted.current) setState(...) не зупиняє сам фоновий проміс і мережеве з'єднання, витрачаючи трафік і пам'ять.
// ❌ Антипатерн: милиця з isMounted маскує витік ресурсів
useEffect(() => {
let isMounted = true;
fetchData().then((data) => {
if (isMounted) setState(data); // Запит усе одно завантажується повністю!
});
return () => {
isMounted = false;
};
}, []);
// ✅ Правильне рішення: фізичне скасування через AbortController
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal })
.then((data) => setState(data))
.catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') return;
console.error(err);
});
return () => controller.abort();
}, []);Створення об'єкта конфігурації всередині тіла компонента та передача його в залежності useEffect призводить до нескінченного циклу рендерингу.
// ❌ Антипатерн: options перестворюється на кожен рендер -> нескінченний цикл
function Widget({ query }: { query: string }) {
const options = { filter: query, page: 1 }; // Новий об'єкт у пам'яті на кожен рендер!
useEffect(() => {
fetchItems(options);
}, [options]); // Нескінченний цикл!
}
// ✅ Правильне рішення: примітиви в залежностях або стабільний useMemo
function WidgetFixed({ query }: { query: string }) {
useEffect(() => {
fetchItems({ filter: query, page: 1 });
}, [query]); // query — стабільний примітивний рядок
}