Вкладка браузера вашего пользователя зависает через тридцать минут. Интерфейс начинает тормозить. Затем браузер вылетает с ошибкой нехватки памяти (out-of-memory).

Вы изучаете код компонента строка за строкой и не видите ничего подозрительного. В этом и заключается безумие утечек памяти в React. Баг кроется не в синтаксисе JSX или логике хуков. Он живет в зазоре между вашим компонентом и сборщиком мусора браузера. Забытый обработчик событий или долгоживущее замыкание мешают сборщику освободить память. Вы размонтируете компонент, но одна единственная оставшаяся ссылка удерживает все дерево в куче (heap). Эти утечки невозможно найти, просто читая код. Проблема остается скрытой под поверхностью, невидимой для глаз и незаметной при юнит-тестировании.

Почему утечки прячутся на виду

JavaScript-движки, такие как V8, управляют памятью автоматически. Когда путь ссылки от корня к объекту отсутствует, движок помечает этот объект как мусор и освобождает место. Этот процесс работает отлично до тех пор, пока скрытая ссылка не переживет дольше, чем вы планировали.

В React опасность часто возникает на границе между компонентами и DOM. Вы можете прикрепить слушатель resize к window внутри модального окна или подписаться на WebSocket в виджете дашборда. Когда пользователь закрывает модальное окно или переходит на другую страницу, компонент размонтируется. Если подписка продолжает работать, движок видит валидную ссылку от глобального объекта window к вашему обработчику, а от обработчика — обратно в замыкание компонента. Компонент, его пропсы, состояние и все поддерево DOM-узлов остаются закрепленными в памяти. За сотни взаимодействий эти объекты накапливаются. Потребление памяти растет по пилообразной траектории, которая никогда не возвращается к исходному уровню.

Проблема корпоративных дашбордов

Чаще всего это случается в корпоративных дашбордах, где пользователи проводят на одной странице часами. Представьте панели мониторинга, аналитические отчеты или системы тикетов. Пользователь открывает модальное окно с деталями, фильтрует большой набор данных или переключает вкладки внутри одностраничного приложения. Каждое отдельное взаимодействие проходит гладко. Однако со временем накапливаются «осиротевшие» узлы и отсоединенные слушатели. Приложение замедляется не из-за одного тяжелого рендеринга, а потому, что куча разрастается настолько, что это вызывает частые и длительные паузы на сборку мусора.

Хватит гадать на своих хуках useEffect. Единственный способ узнать, есть ли утечка — это измерить кучу напрямую. Chrome DevTools дает такую возможность.

Охота на утечки с помощью Chrome DevTools

Вам понадобится воспроизводимая последовательность действий и несколько минут сосредоточенного внимания. Откройте ваше приложение в Chrome, запустите DevTools и перейдите на вкладку Memory.

Зафиксируйте базовый уровень. Выберите Heap snapshot и нажмите Take snapshot. Это зафиксирует каждый объект, находящийся в данный момент в JavaScript-куче, и покажет начальное потребление памяти. Делайте это после того, как страница перейдет в состояние покоя, а не во время начальной загрузки, чтобы измерять только рост, вызванный действиями пользователя.

Выполните действие. Совершите именно то взаимодействие с интерфейсом, которое, как вы подозреваете, вызывает утечку. Откройте и закройте модальное окно, переключите сложный график или смените маршрут и вернитесь назад. По завершении верните приложение в исходное визуальное состояние. Этот шаг критически важен. Интерфейс должен выглядеть точно так же, как во время замера базового уровня. Если куча выросла, хотя интерфейс выглядит пустым, у вас есть веские доказательства утечки.

Принудительно запустите сборку мусора. Нажмите на иконку корзины на вкладке Memory. Это запустит полный цикл GC и очистит временные объекты, которые законно дожили до следующей сборки. То, что останется, — это реальные утечки: объекты, которые должны были быть удалены, но удерживались случайными ссылками.

Сделайте второй снимок. Снова нажмите Take snapshot. Теперь у вас есть два «снимка» кучи, сделанных при одинаковых условиях интерфейса.

Сравните результаты. Измените режим отображения с Summary на Comparison. Установите первый снимок в качестве базового (baseline), а второй — в качестве сравниваемого. Режим Comparison выводит список всех категорий объектов и показывает дельту — чистое изменение количества объектов между двумя снимками.

Отсортируйте по Delta. Ищите категории, количество которых значительно увеличилось. Обратите особое внимание на Detached HTMLElement и узлы React fiber. Detached HTML element — это DOM-узел, который больше не прикреплен к активному дереву документа, но на него все еще ссылается какой-то JavaScript-объект. Это неопровержимые улики. Их не должно быть после того, как вы закрыли модальное окно или размонтировали компонент.

Чтение пути удержания (Retaining Path)

Когда вы выбираете отсоединенный (detached) элемент в снимке (snapshot), Chrome показывает путь удержания (retaining path) в нижней панели. Этот путь представляет собой цепочку ссылок от корня до выбранного объекта. Внимательно проследите за ним. Часто вы обнаружите обработчик событий (event listener), IntersectionObserver, ID от setInterval или замыкание (closure), указывающее на конкретную строку в вашем компоненте.

Ищите знакомые названия. Если вы видите слушатель, прикрепленный к window, с именем функции из вашего кода, вы нашли «якорь». Объект, удерживающий этот слушатель, поддерживает жизнь во всем вашем компоненте. Иногда цепочка проходит через стороннюю библиотеку. В таких случаях проверьте, не требует ли библиотека явного вызова метода завершения работы (teardown), который вы забыли вызвать в функции очистки (cleanup function).

Устранение первопричин

Как только вы определите путь удержания, исправление обычно носит механический характер, но требует дисциплины от всей команды.

Используйте функции очистки. Всегда возвращайте функцию очистки в useEffect, когда добавляете слушатели к window или document. Если ваш эффект подписывается на событие изменения размера (resize), удалите эту подписку до того, как компонент размонтируется (unmount). Очистка выполняется, когда React уничтожает компонент, предоставляя вам гарантированную возможность разорвать внешние связи.

Стабилизируйте ссылки. Оборачивайте ваши обработчики в useCallback. Это гарантирует, что вы передадите в removeEventListener ту же самую ссылку на функцию, которую изначально передали в addEventListener. Если вы регистрируете инлайновую функцию, например window.addEventListener('resize', () => { ... }), а затем пытаетесь удалить её другой инлайновой функцией, ссылки не совпадут. Слушатель останется прикрепленным. Замыкание внутри него будет поддерживать жизнь в состоянии вашего компонента. useCallback со стабильным массивом зависимостей предотвращает это несоответствие идентичностей.

Разрывайте глобальные связи. Помните, что объекты-цели событий в браузере, такие как window и document, живут на протяжении всего времени работы страницы. Любая ссылка от них на ваш компонент действует как глобальный якорь. Удаление слушателя разрывает этот якорь и позволяет движку V8 очистить состояние компонента и узлы DOM во время следующего цикла сборки мусора (garbage collection).

Рассмотрим модальное окно, которое отслеживает ширину окна. Без очистки каждый раз, когда пользователь открывает модальное окно, прикрепляется новый слушатель. Старые никогда не отсоединяются, потому что экземпляры компонентов, которым они принадлежали, исчезли, а сами функции были анонимными и потеряны. Использование именованного обработчика, обернутого в useCallback, плюс функции очистки, которая вызывает removeEventListener, позволяет чисто завершить цикл.

Практический вывод

Утечки памяти в React редко заявляют о себе четким сообщением об ошибке. Они заявляют о себе вкладкой, которая становится всё «тяжелее» тем дольше она открыта. Не тратьте время на догадки о том, какой хук виноват. Откройте вкладку Memory, принудительно запустите сборку мусора и сравните снимки (snapshots). Пусть heap profiler покажет вам точный путь удержания. Затем напишите функцию очистки, стабилизируйте ссылку на callback и разорвите глобальную связь. Ваши пользователи не заметят исправления напрямую, но они заметят, что дашборд продолжает работать плавно даже в конце рабочего дня.