Как решить проблему утечек памяти в useEffect?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Утечки памяти в useEffect и как их предотвратить
Утечки памяти в React происходят, когда компоненты умирают, но оставляют после себя активные подписки, таймеры или DOM-слушатели. Это приводит к неконтролируемому росту использования памяти.
Проблема 1: Активные subscription при unmount
Когда компонент размонтируется, его subscription остаётся активной и продолжает обновлять state несуществующего компонента:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Подписываемся на сокет
socket.on('user-update', (data) => {
setUser(data); // Проблема: setUser вызывается после unmount!
});
}, []);
return <div>{user?.name}</div>;
}
// Сценарий:
// 1. Компонент монтируется -> подписываемся на socket
// 2. Пользователь навигирует на другую страницу
// 3. Компонент размонтируется
// 4. Socket продолжает отправлять события
// 5. setUser() вызывается на несуществующем компоненте
// 6. React выдаёт предупреждение "Memory leak detected"
Решение 1: Cleanup функция (return из useEffect)
Верни функцию из useEffect — она будет вызвана при unmount:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const handleUpdate = (data) => {
setUser(data);
};
socket.on('user-update', handleUpdate);
// CLEANUP функция
return () => {
socket.off('user-update', handleUpdate);
};
}, []);
return <div>{user?.name}</div>;
}
// Теперь при unmount слушатель удаляется правильно
Проблема 2: Таймеры и интервалы
Таймеры продолжают работать даже после unmount компонента:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// Интервал продолжит работать после unmount
const interval = setInterval(() => {
setSeconds(s => s + 1); // Warning при unmount
}, 1000);
}, []);
return <div>{seconds}s</div>;
}
// Оптимизация
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => {
clearInterval(interval); // Очищаем при unmount
};
}, []);
return <div>{seconds}s</div>;
}
Проблема 3: Fetch запросы
Запросы продолжают обрабатываться после unmount, вызывая setUser на мёртвом компоненте:
function UserData({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data); // Проблема: может выполниться после unmount
});
}, [userId]);
return <div>{user?.name}</div>;
}
// Решение 1: AbortController
function UserData({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
setUser(data);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => {
controller.abort(); // Отменяем запрос при unmount
};
}, [userId]);
return <div>{user?.name}</div>;
}
// Решение 2: Проверка наличия компонента
function UserData({ userId }) {
const [user, setUser] = useState(null);
const isMountedRef = useRef(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
// Проверяем живой ли компонент
if (isMountedRef.current) {
setUser(data);
}
});
return () => {
isMountedRef.current = false;
};
}, [userId]);
return <div>{user?.name}</div>;
}
Проблема 4: DOM слушатели
Добавленные слушатели не удаляются при unmount:
function ScrollListener() {
useEffect(() => {
const handleScroll = () => {
console.log('scrolling');
};
window.addEventListener('scroll', handleScroll);
// Проблема: слушатель остаётся в window
// Теперь все компоненты на странице слышат этот слушатель
}, []);
return <div>Content</div>;
}
// Оптимизация
function ScrollListener() {
useEffect(() => {
const handleScroll = () => {
console.log('scrolling');
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return <div>Content</div>;
}
Проблема 5: Замыкания в зависимостях
Если не указать зависимость, старые функции остаются в памяти:
function DataFetcher({ url }) {
useEffect(() => {
fetch(url).then(/* ... */);
// Проблема: url изменится, но effect будет использовать старый url
}, []); // Забыл добавить url в зависимости!
}
// Правильно
function DataFetcher({ url }) {
useEffect(() => {
fetch(url).then(/* ... */);
}, [url]); // Указана зависимость
}
Шаблон для безопасного useEffect
function SafeComponent() {
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
let timeoutId;
const setupListeners = async () => {
try {
// Асинхронные операции
const data = await fetch('url', { signal: controller.signal })
.then(r => r.json());
if (isMounted) {
// Только обновляем state если компонент ещё живой
// setState(data);
}
} catch (err) {
if (err.name !== 'AbortError' && isMounted) {
console.error(err);
}
}
};
// Слушатели
window.addEventListener('resize', () => {});
// Таймеры
timeoutId = setTimeout(() => {}, 1000);
setupListeners();
// CLEANUP - всё очищаем
return () => {
isMounted = false;
controller.abort();
clearTimeout(timeoutId);
window.removeEventListener('resize', () => {});
};
}, []);
return <div></div>;
}
Чеклист для предотвращения утечек
- Все слушатели событий удаляются в cleanup функции
- Все таймеры (setTimeout, setInterval) очищаются
- Fetch запросы либо отменяются (AbortController), либо проверяется isMounted
- Все subscription отписываются
- Зависимости правильно указаны в массиве dependencies
- Используется ESLint плагин
react-hooksдля проверки
Вывод: утечки памяти в useEffect — это классическая ошибка. Всегда возвращай cleanup функцию, очищай таймеры, отменяй запросы и удаляй слушатели событий.