useEffect как синхронизация, зависимости, очистка, ссылки, useEffectEvent, гонки запросов и отмена работы.
Эффект нужен не после каждой отрисовки, а только для синхронизации состояния React с системой вне React.
Вы удалите лишние эффекты, напишете симметричные подписки, устраните устаревшие замыкания и гонки запросов и правильно примените ref.
Понадобятся этапы отрисовки и фиксации, снимки состояния, замыкания и отмена асинхронной работы. Итог урока — виджет живого соединения с корректным жизненным циклом, отменой устаревших запросов и управлением фокусом.
Эффект выполняется после фиксации DOM и приводит внешний ресурс в соответствие текущим свойствам и состоянию: соединение, API браузера, сторонний виджет или подписку. Если значение можно вычислить во время отрисовки либо действие вызвано событием пользователя, эффект не нужен. До написания кода назовите ресурс и способ его освобождения.
Самый частый лишний эффект вычисляет то, что и так выводится из свойств:
// ❌ внешней системы нет: эффект и состояние здесь не нужны
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ производное значение вычисляется во время отрисовки
const fullName = `${firstName} ${lastName}`;Первый вариант даёт лишнюю фиксацию DOM и кадр, в котором fullName ещё пустое. Проверка простая: назовите внешнюю систему. Если её нет — эффект лишний.
Проверьте себя. Для каждого эффекта назовите внешнюю систему; удалите эффект без такой системы.
Частая ошибка. Пересчитывать fullName из firstName и lastName через эффект и состояние.
Эффект возвращает функцию очистки, которая отменяет именно выполненное подключение: удаляет тот же обработчик, отписывается, разрывает соединение или очищает таймер. Перед повторным запуском React сначала вызывает прежнюю очистку; при размонтировании — последнюю. Такая симметрия делает повторное монтирование безопасным.
Очистка должна снимать ровно то, что было добавлено, — и это требует одной и той же ссылки на функцию:
// ❌ removeEventListener получает новую функцию: обработчик остаётся навсегда
useEffect(() => {
window.addEventListener('resize', () => setWidth(window.innerWidth));
return () => window.removeEventListener('resize', () => setWidth(window.innerWidth));
}, []);
// ✅ одна ссылка на добавление и снятие
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Проверить симметрию можно счётчиком: после смены roomId и размонтирования число активных обработчиков и соединений должно вернуться к нулю. StrictMode делает эту проверку автоматически, повторяя подключение и очистку в разработке.
Проверьте себя. Проверьте число обработчиков после смены roomId и размонтирования.
Частая ошибка. Передать removeEventListener новую функцию, не совпадающую с добавленной.
Все реактивные значения, прочитанные эффектом, входят в список зависимостей. Этот список следует из кода, а не задаёт желаемую частоту запуска. Чтобы изменить зависимости, перестройте код: создайте объект внутри эффекта, вынесите константу из компонента, используйте функцию обновления или Effect Event. Отключение правила линтера оставляет устаревшее замыкание (stale closure).
Объект, созданный при отрисовке, — новая ссылка каждый раз, поэтому эффект запускается бесконечно:
// ❌ options новый на каждой отрисовке: эффект перезапускается всегда
const options = { serverUrl, roomId };
useEffect(() => {
const connection = connect(options);
return () => connection.close();
}, [options]);
// ✅ объект создаётся внутри эффекта, в зависимостях — первичные значения
useEffect(() => {
const connection = connect({ serverUrl, roomId });
return () => connection.close();
}, [serverUrl, roomId]);Отключение правила линтера решает симптом и создаёт худшую проблему: эффект продолжит читать значения первой отрисовки. JSON.stringify(options) в зависимостях тоже не решение — он скрывает настоящую структуру зависимостей и ломается на порядке ключей.
Проверьте себя. Исправьте эффект с объектом в зависимостях без JSON.stringify и отключения линтера.
Частая ошибка. Оставить пустой массив, хотя эффект читает меняющийся roomId.
Несколько асинхронных операций могут завершиться не по порядку. Очистка помечает результат устаревшим или отменяет поддерживаемый fetch через AbortController. Отмена не всегда останавливает работу сервера и может опоздать, поэтому результат всё равно связывают с текущим запросом. Ошибку отмены обычно не показывают пользователю как сбой.
Полная защита состоит из двух независимых механизмов — отмены и проверки актуальности:
// src/features/search/useResults.ts
useEffect(() => {
const controller = new AbortController();
let active = true; // страховка: отмена может опоздать
void (async () => {
try {
const data = await fetchResults(query, { signal: controller.signal });
if (active) setResults(data); // применяем только результат текущего запроса
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return; // не сбой
if (active) setError(error);
}
})();
return () => {
active = false;
controller.abort();
};
}, [query]);Без флага active возможен сценарий, когда отмена не успела: старый ответ уже пришёл в обработчик, и setResults перезапишет данные нового запроса. Отдельно важно не показывать AbortError как ошибку пользователю — это штатное завершение отменённой работы, а не сбой.
Проверьте себя. Искусственно задержите старый запрос дольше нового и проверьте итог.
Частая ошибка. Применять последний завершившийся ответ независимо от входных данных эффекта.
ref для данных, не влияющих на интерфейсref сохраняет значение между отрисовками и не вызывает новую отрисовку при записи. Он подходит для DOM-узла, идентификатора таймера, экземпляра внешнего виджета и других данных, не влияющих на JSX. Не читайте и не изменяйте ref.current во время вычисления компонента, кроме предсказуемой ленивой инициализации.
Идентификатор таймера — типичный пример: он нужен для очистки, но не участвует в разметке.
// src/features/timer/useCountdown.ts
const timerRef = useRef<number | null>(null);
useEffect(() => {
timerRef.current = window.setInterval(() => setLeft(value => value - 1), 1000);
return () => {
if (timerRef.current !== null) window.clearInterval(timerRef.current);
};
}, []);Обратный случай — статус, который пользователь должен видеть:
// ❌ запись в ref не вызывает отрисовку: интерфейс останется прежним
statusRef.current = 'saved';
// ✅ отображаемое значение живёт в состоянии
setStatus('saved');Чтение ref.current во время вычисления компонента тоже нарушает контракт: React может вызвать компонент повторно или отбросить результат, и значение окажется прочитанным из другого кадра.
Проверьте себя. Сохраните идентификатор таймера в ref, очистите его при повторном запуске и размонтировании.
Частая ошибка. Хранить отображаемый пользователю статус только в ref.
useLayoutEffect и измерение разметкиuseLayoutEffect запускается после изменения DOM, но до показа кадра, и может синхронно запросить новую отрисовку. Он нужен для измерения расположения и незаметной корректировки. Поскольку такой эффект задерживает кадр, сеть, журналирование и обычные подписки оставляют в useEffect. На сервере измерять разметку нельзя.
Измерение до показа кадра избавляет от заметного «прыжка» подсказки:
// src/shared/ui/Tooltip.tsx
useLayoutEffect(() => {
const rect = tooltipRef.current?.getBoundingClientRect();
if (!rect) return;
// корректируем положение до того, как браузер нарисует кадр
if (rect.right > window.innerWidth) setAlign('left');
}, [content]);С обычным useEffect пользователь успеет увидеть подсказку в неверном положении: кадр уже показан, и исправление произойдёт в следующем. Обратная сторона — блокировка кадра: если поставить в useLayoutEffect сетевой запрос или журналирование, интерфейс подвиснет на время их выполнения. На сервере такой эффект не запускается вовсе, поэтому код в нём не должен быть обязательным для первой отрисовки.
Проверьте себя. Измерьте подсказку и разместите её до показа кадра, затем сравните с обычным эффектом.
Частая ошибка. Заменить все эффекты на useLayoutEffect «для надёжности».
useEffectEvent для нереактивного чтенияEffect Event позволяет эффекту вызвать логику, которая читает последние свойства и состояние, но не должна влиять на повторное подключение. Например, соединение зависит от roomId, а уведомление использует текущую theme. Effect Event вызывают только из эффектов и не передают как обычный обработчик. Настоящую зависимость так скрывать нельзя.
// src/ChatRoom.tsx
const onConnected = useEffectEvent(() => {
showNotification('Подключено', theme);
});
useEffect(() => {
const connection = connect(roomId);
connection.on('connected', onConnected);
return () => connection.close();
}, [roomId]);Смена theme меняет вид уведомления, но не является причиной переподключения к комнате. Границу легко перейти: если спрятать в Effect Event чтение roomId, эффект перестанет переподключаться при смене комнаты и будет держать соединение с прежней.
// ❌ настоящая зависимость спрятана: соединение останется со старой комнатой
const connectToRoom = useEffectEvent(() => connect(roomId));
useEffect(() => {
const connection = connectToRoom();
return () => connection.close();
}, []);Правило разделения простое: значение, определяющее сам ресурс, — зависимость; значение, влияющее только на содержание уведомления, — нереактивное чтение.
Проверьте себя. Смените theme без переподключения и проверьте её значение при следующем событии connected.
Частая ошибка. Убрать roomId из зависимостей, спрятав его чтение в Effect Event.
Цепочка «эффект → обновление состояния → эффект → обновление» создаёт лишние фиксации и хрупкие промежуточные состояния. Одно пользовательское событие часто может выполнить все переходы через обработчик или редьюсер. Производные данные вычисляют во время отрисовки. Для каждого оставшегося эффекта назовите внешнюю систему и правило очистки.
Каскад из трёх эффектов заменяется одним переходом состояния:
// ❌ три фиксации и два промежуточных состояния, которых не должно существовать
useEffect(() => { if (card) setGoldCount(count => count + 1); }, [card]);
useEffect(() => { if (goldCount > 3) setRound(round => round + 1); }, [goldCount]);
useEffect(() => { if (round > 5) setGameOver(true); }, [round]);
// ✅ одно событие — один согласованный переход
function handleCardSelect(nextCard: Card) {
dispatch({ type: 'select_card', card: nextCard }); // редьюсер считает всё сразу
}Редьюсер выполняет переходы в одном вычислении, поэтому невозможно наблюдать состояние «раунд увеличен, но игра ещё не завершена». Для каждого оставшегося эффекта после такой чистки должно быть два ответа: какая внешняя система синхронизируется и как она освобождается.
Проверьте себя. Сверните каскад выбора карточки и сброса раунда в один переход состояния.
Частая ошибка. Координировать рабочий сценарий наблюдением за состоянием после фиксации DOM.
Исправьте чат: соединение пересоздаётся при смене theme, старый запрос перезаписывает новый, обработчик не удаляется, а ref читается во время отрисовки. Примените эффект, очистку, AbortController и useEffectEvent.
Работа готова, когда проверка StrictMode оставляет один ресурс, устаревший ответ не применяется, а зависимости проходят линтер без отключения правил.
Далее: Пользовательские хуки