Интервью-вопрос
Что нужно добавить в useEffect в React, чтобы снять обработчик после закрытия модального окна
Нужно вернуть из useEffect функцию очистки и удалить в ней обработчик. Главный риск в modal: окно может закрыться без размонтирования компонента, поэтому cleanup нужно связать с состоянием открытия и корректными зависимостями.
- Добавлен
- Редакция
Подготовьте короткий ответ и пару деталей на случай уточняющих вопросов.
Мини-квиз
Проверка перед разбором
Несколько быстрых вопросов перед разбором. Так проще поймать места, которые только кажутся понятными.
Вопрос 1 из 50 правильно
Разбор
Разобраться, а не зазубрить
Дальше разбираем суть, типичные уточнения и места, где легко сказать лишнее или перепутать термины.
Базовая идея
В useEffect нужно вернуть функцию очистки. React вызовет ее, когда компонент размонтируется. Он также вызовет ее перед новым запуском этого же эффекта, если изменились зависимости.
Для DOM-событий схема простая. Effect добавляет обработчик через addEventListener, cleanup снимает его через removeEventListener. Это симметричная пара. Если вы подписались на внешний ресурс, вы должны явно отписаться.
Короткий ответ для интервью:
Нужно вернуть из useEffect cleanup-функцию и в ней вызвать removeEventListener с той же функцией-обработчиком. Если modal закрывается через состояние, effect лучше завязать на isOpen, чтобы обработчик жил только пока окно открыто.
Почему для модального окна важен isOpen
Закрытие modal не всегда означает размонтирование компонента. Часто компонент остается в дереве, а видимость управляется через isOpen, CSS-класс или анимацию. В такой схеме cleanup при закрытии не выполнится сам по себе, если зависимости эффекта не меняются.
Поэтому обработчик глобального события, например Escape на document, должен добавляться только когда modal открыт. При переходе isOpen из true в false React выполнит cleanup предыдущего эффекта и снимет обработчик.
Практический пример
Пример безопасного обработчика Escape для modal:
import { useEffect } from "react";
type ModalProps = {
isOpen: boolean;
onClose: () => void;
};
export function Modal({ isOpen, onClose }: ModalProps) {
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
return (
<div role="dialog" aria-modal="true" aria-label="Подтверждение действия">
...
</div>
);
}Здесь обработчик объявлен внутри эффекта, поэтому cleanup снимает ровно ту же ссылку. Зависимости показывают, что эффект зависит от открытия окна и текущего onClose. Если onClose создается в родителе заново на каждый render и это мешает, его можно стабилизировать через useCallback. Просто убрать его из зависимостей без понимания последствий нельзя.
В реальном modal одного Escape мало. Нужны понятное имя диалога, управление фокусом и возврат фокуса после закрытия. Если глобальный обработчик останется после cleanup, он может закрыть уже другой UI и сбить фокус пользователю клавиатуры.
Где чаще всего появляется баг
- 1Открыли modal, effect добавил обработчик
- 2Обработчик использует актуальный onClose
- 3Закрыли modal или изменились зависимости
- 4Cleanup снял тот же обработчик
- 1Обработчик добавляется при каждом открытии
- 2Cleanup отсутствует или снимает другую функцию
- 3Закрытый modal оставляет подписку
- 4Escape или click срабатывают несколько раз
Самый заметный симптом плохого cleanup: после нескольких открытий modal одно нажатие Escape вызывает закрытие несколько раз, лишние запросы, лишнюю аналитику или ошибку обновления состояния. Это значит, что старые обработчики не были сняты.
Еще один риск связан со stale closure. Если обработчик был создан со старым onClose или старым состоянием, он может работать не с теми данными. На интервью лучше сказать, что cleanup и корректные зависимости решают разные части одной задачи. Cleanup убирает старую подписку, зависимости не дают обработчику устареть.
Что делать с addEventListener options
Для снятия обработчика браузеру нужно найти ту же подписку. Обычно важны тип события, ссылка на функцию и значение capture. Если вы добавили обработчик с { capture: true }, снимайте его с тем же capture.
useEffect(() => {
const handleClick = (event: MouseEvent) => {
// обработка клика вне modal
};
document.addEventListener("click", handleClick, true);
return () => {
document.removeEventListener("click", handleClick, true);
};
}, []);Не стоит писать две разные inline-функции. Они могут выглядеть одинаково, но для removeEventListener это разные ссылки.
Если внутри эффекта есть другая внешняя работа
Cleanup для события снимает только подписку на событие. Он не отменяет работу, которая уже стартовала. Если при открытии modal вы запускаете таймер, подписку или сетевой запрос, очищайте их в том же cleanup.
useEffect(() => {
if (!isOpen) {
return;
}
const controller = new AbortController();
fetch("/api/modal-data", { signal: controller.signal }).catch((error) => {
if (error.name !== "AbortError") {
// показать error state или отправить ошибку в лог
}
});
return () => {
controller.abort();
};
}, [isOpen]);Иначе закрытие окна остановит только новые события, но старый запрос может вернуться позже и перезаписать актуальное состояние. На практике это выглядит как race condition, мигание данных или ошибка после закрытия modal.
Практический вывод
Хороший ответ не ограничивается фразой добавить cleanup. Скажите, что именно очищаете, когда React вызовет очистку и как это связано с modal.
Сильная формулировка:
Я добавлю return из useEffect. Внутри верну функцию, которая снимает обработчик через removeEventListener с той же ссылкой на handler. Если modal управляется через isOpen, добавлю isOpen в зависимости и буду подписываться только когда окно открыто. Это защитит от утечек, повторных срабатываний и обработчиков со старым состоянием.
Частые ошибки
Где обычно ошибаются
Проверьте формулировки, которые звучат уверенно, но на интервью быстро выдают пробелы.
- 1
Не вернуть cleanup из useEffect
Если вы не вернете функцию очистки, глобальный обработчик останется подписанным после закрытия модального окна. Пользователь может нажать Escape уже на другом экране, а старый обработчик все равно попробует изменить состояние или закрыть не тот UI.
- 2
Снимать не ту функцию
removeEventListenerработает только с той же ссылкой на функцию. Если вы добавили обработчик через одну inline-функцию, а удаляете через другую, браузер не найдет подписку и ничего не снимет. - 3
Игнорировать состояние открытия modal
Если компонент остается в дереве, но modal просто скрывается, cleanup при закрытии сам не случится. Свяжите effect с
isOpenили условно рендерите modal, иначе обработчик будет активен, пока жив компонент. - 4
Занижать зависимости эффекта
Пустой массив зависимостей может оставить в обработчике старый
onClose, старые props или старое состояние. Лучше указать реальные зависимости или стабилизировать колбэк черезuseCallback, если это действительно нужно. - 5
Добавлять listener в render или вне эффекта
Такой код может добавлять новый обработчик на каждый render и ломаться при SSR, если обратиться к
documentдо клиента. Безопаснее добавлять browser-only обработчик внутриuseEffectи возвращать cleanup. - 6
Не учитывать options у addEventListener
Если обработчик был добавлен с
capture: true, снимать его нужно с таким же capture. Иначе cleanup выглядит правильным, но событие продолжает приходить. - 7
Думать, что cleanup события отменяет всю работу
Удаление listener не отменит уже начатый
fetch, таймер или подписку внутри эффекта. Если modal закрыли, эти операции нужно отменить отдельно, иначе можно получить лишний запрос, race condition или обновление уже неактуального UI.
Follow-up
Что могут спросить дальше
Короткие ответы на вопросы, которыми проверяют понимание useEffect, cleanup и подписок на события.
Живые ответы
Видео с похожим вопросом
Если найдем публичные интервью с таким вопросом, добавим их сюда. Их удобно смотреть после теории, чтобы свериться с живыми ответами.
Пока видео нет. Когда появятся подходящие публичные интервью, добавим их в этот блок, чтобы можно было сравнить разбор с тем, как отвечают реальные кандидаты.
Что если передать массив в useEffect 😎
Массив во втором аргументе useEffect задает зависимости эффекта. Разбираем пустой массив, зависимости по ссылке, повторные запуски, cleanup и типичные бесконечные циклы.
Что происходит с JSX при рендере 😎
JSX заранее компилируется в JavaScript-вызовы, которые создают React-элементы. При рендере React вызывает компоненты, строит новое описание UI, сравнивает его с прошлым и применяет нужные изменения к DOM.