Хук useEffect предназначен для синхронизации компонента React с внешними системами: DOM API, сетевыми запросами, таймерами или сторонними подписками. Непонимание механизмов замыканий (closures) и жизненного цикла эффекта приводит к трем классическим проблемам: утечкам памяти, устаревшим данным в состоянии (stale closure) и состоянию гонки (race conditions) при асинхронных запросах.
Разберем, как устроен жизненный цикл эффекта, как правильно управлять зависимостями и очисткой, а также как писать надежные тесты для проверки этих сценариев.
Как устроен жизненный цикл эффекта
Эффект в React — это не прямой аналог методов componentDidMount или componentDidUpdate. Это декларативный механизм синхронизации. Каждый запуск эффекта логически изолирован и привязан к конкретному рендеру со своими пропсами и состоянием.
Модель «подключение — очистка»
Жизненный цикл эффекта строится по принципу симметрии:
- Рендер и коммит: React отрисовывает JSX и обновляет DOM.
- Setup: React запускает функцию, переданную в
useEffect. - Перед следующим запуском (при изменении зависимостей): React сначала вызывает функцию очистки (cleanup) от предыдущего рендера со старыми значениями, и только потом выполняет новый
setupс актуальными данными. - Размонтирование (Unmount): React вызывает финальный
cleanup.
Рендер 1 ---> Setup 1
| (изменились зависимости)
Рендер 2 ---> Cleanup 1 ---> Setup 2
| (компонент удален из дерева)
Размонтирование ---> Cleanup 2
Поведение React StrictMode в режиме разработки
В режиме StrictMode (начиная с React 18) в среде разработки каждый компонент монтируется, размонтируется и снова монтируется:
Setup →Cleanup →Setup
Это поведение спроектировано для выявления эффектов, у которых отсутствует или некорректно реализована функция очистки. Если компонент подписывается на событие в setup, но не отписывается в cleanup, двойной вызов сразу удвоит количество слушателей и проявит утечку памяти.
Массив зависимостей: почему React сравнивает ссылки через Object.is
Второй аргумент useEffect — массив зависимостей. React проверяет каждое значение из массива между рендерами с помощью алгоритма Object.is.
Реактивные значения и правило полного массива зависимостей
Реактивными считаются любые значения, объявленные внутри компонента:
propsstate- переменные и функции, вычисленные на базе
propsилиstate.
Если эффект использует реактивное значение, оно обязано присутствовать в массиве зависимостей. Игнорирование правила линтера react-hooks/exhaustive-deps приводит к чтению устаревших переменных из замыкания первого рендера.
Проблема нестабильных объектов и функций
Поскольку Object.is сравнивает объекты и функции по ссылке, их создание непосредственно в теле компонента приводит к повторному запуску эффекта на каждый рендер:
function UserProfile({ userId }) {
// Ошибка: options создается заново при каждом рендере (новая ссылка в памяти)
const options = { timeout: 1000 };
useEffect(() => {
fetchUserData(userId, options);
}, [userId, options]); // Эффект будет срабатывать повторно при любом рендере компонента
}
Решения:
- Вынести неизменяемый объект или функцию за пределы компонента.
- Поместить объект внутрь самого эффекта, если он больше нигде не используется.
- Мемоизировать значения через
useMemoилиuseCallback.
Ловушка Stale Closure: почему таймер видит старый state
Замыкание (closure) в JavaScript фиксирует значения переменных на момент своего создания. Если эффект с таймером запускается один раз (с пустым массивом []), его коллбэк навсегда сохраняет ссылку на состояние из первого рендера.
Проблема
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// count ВСЕГДА равен 0, так как взят из замыкания первого рендера
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // Пустой массив: интервал не перезапускается, но значение застряло
}
Решение через функциональный updater
Чтобы обновить состояние на основе его предыдущего значения без включения самого состояния в зависимости, используется функциональная форма setState:
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // Читает актуальное значение из внутреннего хранилища React
}, 1000);
return () => clearInterval(id);
}, []); // Зависимость от count устранена, таймер стабилен
Очистка ресурсов (Cleanup): подписки, сокеты и слушатели событий
Функция очистки должна полностью нейтрализовать сайд-эффект, созданный в setup.
Правило симметричной отписки
Каждому действию в setup должно соответствовать противоположное действие в cleanup:
| Действие в Setup | Действие в Cleanup |
|---|---|
addEventListener |
removeEventListener |
setInterval / setTimeout |
clearInterval / clearTimeout |
socket.subscribe() |
socket.unsubscribe() / socket.close() |
observer.observe(node) |
observer.disconnect() |
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener('resize', handleResize);
// Обязательная симметричная очистка
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
Race Conditions в асинхронных запросах: два метода решения
Состояние гонки (race condition) возникает, когда пользователь быстро меняет параметры (например, переключает вкладки с ID 1 на ID 2).
- Отправляется запрос A для
userId=1(занимает 1500 мс). - Пользователь выбирает
userId=2, отправляется запрос B (занимает 300 мс). - Запрос B завершается первым и отображает данные пользователя
2. - Запрос A завершается позже и перезаписывает экран устаревшими данными пользователя
1.
Время ---->
Запрос A (ID=1): [========================] -> Запись в State (Ошибка!)
Запрос B (ID=2): [======] -> Запись в State
Способ 1. Булев флаг отмены (ignore = true)
Флаг отслеживает, актуален ли еще текущий прогон эффекта. Сетевой запрос завершается в фоне, но его результат игнорируется.
useEffect(() => {
let ignore = false;
async function loadData() {
const data = await fetchUserData(userId);
if (!ignore) {
setUser(data);
}
}
loadData();
return () => {
ignore = true; // При смене userId или unmount старый результат будет проигнорирован
};
}, [userId]);
Способ 2. AbortController и правильная обработка AbortError
Использование AbortController не просто игнорирует результат, а физически обрывает HTTP-соединение на уровне браузера, экономя трафик и ресурсы клиента.
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
const data = await response.json();
setUser(data);
} catch (error) {
// Браузер генерирует AbortError при вызове abort()
if (error.name !== 'AbortError') {
setError(error);
}
}
}
loadData();
return () => {
controller.abort(); // Обрываем сетевой запрос
};
}, [userId]);
Важно: Экземпляр
AbortControllerодноразовый. После вызоваabort()егоsignalнавсегда переходит в состояниеaborted=true. Для каждого нового запуска эффекта создается новый экземплярnew AbortController().
Практические кейсы: разбор 3 типовых ошибок с тестами
Кейс 1: Stale closure в setInterval
Ошибка
// CounterBad.jsx
export function CounterBad() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <div data-testid="count">{count}</div>;
}
Исправление
// CounterGood.jsx
export function CounterGood() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <div data-testid="count">{count}</div>;
}
Тест (React Testing Library + Jest Fake Timers)
import { render, screen, act } from '@testing-library/react';
import { CounterGood } from './CounterGood';
describe('CounterGood', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
act(() => {
jest.runOnlyPendingTimers();
});
jest.useRealTimers();
});
test('корректно увеличивает счетчик каждую секунду', () => {
const { unmount } = render(<CounterGood />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByTestId('count')).toHaveTextContent('3');
unmount();
// Проверка, что после размонтирования таймер не тикает
act(() => {
jest.advanceTimersByTime(2000);
});
});
});
Кейс 2: Неосвобождённый window.addEventListener
Ошибка
// WindowTrackerBad.jsx
export function WindowTrackerBad() {
useEffect(() => {
const handleScroll = () => console.log('scroll');
window.addEventListener('scroll', handleScroll);
// Cleanup отсутствует: утечка памяти
}, []);
return <div>Tracker</div>;
}
Исправление
// WindowTrackerGood.jsx
export function WindowTrackerGood() {
useEffect(() => {
const handleScroll = () => console.log('scroll');
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
return <div>Tracker</div>;
}
Тест на вызов отписки при размонтировании
import { render } from '@testing-library/react';
import { WindowTrackerGood } from './WindowTrackerGood';
test('удаляет слушатель событий window при размонтировании', () => {
const addSpy = jest.spyOn(window, 'addEventListener');
const removeSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = render(<WindowTrackerGood />);
expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
unmount();
expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
addSpy.mockRestore();
removeSpy.mockRestore();
});
Кейс 3: Состояние гонки при смене ID пользователя
Ошибка
// UserProfileBad.jsx
export function UserProfileBad({ userId, fetcher }) {
const [data, setData] = useState(null);
useEffect(() => {
fetcher(userId).then(res => setData(res));
}, [userId, fetcher]);
return <div data-testid="user-data">{data}</div>;
}
Исправление
// UserProfileGood.jsx
export function UserProfileGood({ userId, fetcher }) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetcher(userId).then(res => {
if (!ignore) {
setData(res);
}
});
return () => {
ignore = true;
};
}, [userId, fetcher]);
return <div data-testid="user-data">{data}</div>;
}
Тест на предотвращение состояния гонки через rerender
import { render, screen, act } from '@testing-library/react';
import { UserProfileGood } from './UserProfileGood';
test('игнорирует ответ от медленного первого запроса в пользу второго', async () => {
let resolveUser1;
let resolveUser2;
const mockFetcher = jest.fn((id) => {
if (id === 1) {
return new Promise(resolve => { resolveUser1 = resolve; });
}
return new Promise(resolve => { resolveUser2 = resolve; });
});
const { rerender } = render(<UserProfileGood userId={1} fetcher={mockFetcher} />);
// Быстро меняем prop на userId = 2
rerender(<UserProfileGood userId={2} fetcher={mockFetcher} />);
// Запрос 2 завершается первым
await act(async () => {
resolveUser2('Данные пользователя 2');
});
expect(screen.getByTestId('user-data')).toHaveTextContent('Данные пользователя 2');
// Медленный запрос 1 завершается позже
await act(async () => {
resolveUser1('Данные пользователя 1');
});
// Экран не должен перезаписаться старыми данными
expect(screen.getByTestId('user-data')).toHaveTextContent('Данные пользователя 2');
});
Чек-лист: правила написания безопасных эффектов
- Не выключайте линтер
exhaustive-deps: Все переменные из области видимости компонента, используемые в эффекте, должны быть указаны в массиве. - Пишите симметричный cleanup: Любая подписка, таймер или слушатель DOM обязаны иметь обратное действие в возвращаемой функции.
- Используйте функциональный
setState: Если новое состояние рассчитывается на основе старого, передавайте коллбэкprev => ..., чтобы не добавлять состояние в зависимости. - Защищайте асинхронные операции: Используйте
AbortController(для отмены запросов) или булев флагignore(для защиты состояния от устаревших промисов). - Не делайте коллбэк эффекта асинхронным: Сигнатура
useEffect(async () => ...)запрещена. Создавайте внутреннююasyncфункцию внутри тела эффекта. - Выносите статичные структуры: Константы и чистые функции выносите за пределы компонента, чтобы
Object.isне провоцировал лишние ререндеры.
Часто задаваемые вопросы (FAQ)
Почему нельзя передавать асинхронную функцию напрямую в useEffect(async () => ...)?
React ожидает, что функция setup вернет либо функцию очистки (cleanup), либо undefined. Асинхронная функция неявно возвращает Promise. Если вернуть промис, React не сможет зарегистрировать функцию очистки, что приведет к ошибкам выполнения и утечкам памяти.
В чём разница между флагом ignore и вызовом AbortController.abort()?
Флаг ignore предотвращает запись данных в состояние компонента, но сам запрос выполняется до конца и потребляет полосу пропускания. Метод controller.abort() обрывает соединение на транспортном уровне браузера. AbortController предпочтителен для тяжелых запросов, но для сторонних SDK без поддержки отмены ignore остается надежной альтернативой.
Зачем React в StrictMode вызывает эффекты дважды?
Двойной прогон (setup → cleanup → setup) в среде разработки выявляет эффекты с отсутствующей функцией очистки. Это гарантирует, что логика компонента устойчива к быстрым циклам монтирования/размонтирования, которые часто происходят при навигации и анимациях.
Нужно ли оборачивать каждую функцию в useCallback, если она вызывается в useEffect?
Нет. В большинстве случаев функцию лучше переместить внутрь тела самого useEffect. Оборачивать функцию в useCallback имеет смысл только тогда, когда она одновременно используется в нескольких эффектах или передается дочерним компонентам через пропсы.
Как реагировать на AbortError при использовании fetch?
При отмене запроса через AbortController метод fetch отклоняет промис с DOMException AbortError. Это штатное поведение, а не сбой приложения. В блоке catch необходимо проверять error.name === 'AbortError' и игнорировать такую ошибку, не записывая ее в состояние ошибок интерфейса.
Можно ли повторно использовать один и тот же AbortController в нескольких эффектах?
Нет. Как только вызван метод abort(), сигнал переходит в статус aborted навсегда. Все последующие сетевые запросы, использующие этот же сигнал, будут немедленно отклоняться. Для каждого запуска эффекта должен создаваться новый экземпляр контроллера.
Заключение
Качественная работа с useEffect строится на понимании его природы как инструмента синхронизации. Эффект должен быть симметричным, самодостаточным и изолированным. Использование AbortController, функциональных апдейтеров состояния и строгий контроль массива зависимостей устраняют большинство скрытых багов с утечками памяти и рассинхронизацией интерфейса еще на этапе разработки.
Источники
- AbortSignal
- AbortController - Web APIs | MDN
- AbortController.abort() - Web API - MDN Web Docs - Mozilla
- developer.mozilla.org › docs › WebUsing the Fetch API - Web APIs | MDN - MDN Web Docs
- AbortController.signal - Интерфейсы веб API - MDN - Mozilla
- abort - Интерфейсы веб API - MDN Web Docs - Mozilla



.svg.webp)



