Эффекты — это про синхронизацию
useEffect синхронизирует React с внешней системой — подписками, браузерными API, не-React-виджетами, соединениями — и очищает их; это не хук «реагируй на состояние» для вычисления производных данных, что и есть источник большинства багов с эффектами.
Ты знаешь, что useEffect делает: запускается после рендера, перезапускается при изменении зависимостей и очищается при размонтировании. Но «что он делает» — это не «для чего он», и разрыв между этими двумя рождает большинство багов с эффектами. Инженеры выучивают механику и заключают, что useEffect — это место для «кода, который должен запуститься, когда что-то изменилось». Это заключение неверно, и оно разрастается в кодовую базу из каскадных эффектов, за которыми никто не уследит.
У useEffect ровно одна задача: держать React синхронизированным с системой, живущей вне React. WebSocket, браузерный document.title, библиотека графиков на <canvas>, наблюдатель геолокации. Если в кадре нет внешней системы, шансы подавляюще на то, что эффект тебе вообще не нужен.
После этого урока ты можешь заявить, что назначение эффекта — синхронизировать React с внешней системой, а не реагировать на изменения состояния; написать корректный эффект, который подписывается на внешнюю систему или настраивает её и разбирает её в функции очистки; распознать доминирующий режим отказа — использование эффекта для вычисления производных данных из пропсов или состояния; и применять senior-эвристику: назови внешнюю систему, с которой синхронизируется эффект, а если не можешь — удали эффект.
Эффект — это запасной выход из потока рендеринга React; он существует, чтобы дотянуться до системы, которую React не контролирует. Рендеринг чист: даны пропсы и состояние — ты описываешь UI, а всем остальным владеет React. Но в реальном мире есть вещи, о которых React ничего не знает: сетевые сокеты, document.title, localStorage, императивный экземпляр графика, API IntersectionObserver. Эффект — это санкционированный способ выйти за пределы рендера, тронуть эту внешнюю вещь и держать её в ногу с текущим состоянием твоего компонента.
// каноническая форма: настрой внешнюю систему, верни её разбор
useEffect(() => {
const connection = createConnection(roomId); // выходим за пределы React
connection.connect();
return () => connection.disconnect(); // разбор при очистке
}, [roomId]); // ре-синхр. при изменении roomIdЧитай этот массив зависимостей как «ре-синхронизируй всякий раз, когда меняется roomId», а не «запусти это, когда меняется roomId». Формулировка важна: эффект синхронизирует; он не реагирует.
Корректный эффект всегда описывает двусторонний контракт: настрой и разбери. Функция очистки — не опциональная вежливость, это половина паттерна. React может запустить твой эффект, а затем позже перезапустить его (зависимости изменились) или размонтировать компонент, и у каждой настройки должен быть парный разбор, иначе ты протекаешь. Подписка без unsubscribe, setInterval без clearInterval, обработчик события, который так и не сняли, — каждый накапливается по рендерам и перемонтированиям.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const socket = new WebSocket(`wss://chat.example.com/${roomId}`);
const onMessage = (e: MessageEvent) => console.log(e.data);
socket.addEventListener("message", onMessage);
return () => {
socket.removeEventListener("message", onMessage);
socket.close(); // разбор точно зеркалит настройку
};
}, [roomId]);
return <div>Room: {roomId}</div>;
}В Strict Mode из React 19 эффект намеренно запускается как настройка→очистка→настройка в разработке, чтобы немедленно вскрыть отсутствующий разбор. Если твой эффект ломается при этом двойном вызове — очистка неверна; это тест делает свою работу, а не баг, который надо подавить.
Синхронизация с браузерным API — тот же паттерн, даже когда это выглядит как «просто выставить значение». Установка document.title из состояния твоего компонента — это синхронизация: document — внешняя система, и ты держишь её в ногу с состоянием React. Этому место в эффекте, и (поскольку заголовок сохраняется) в идеале он восстанавливается при очистке.
function PageTitle({ unread }: { unread: number }) {
useEffect(() => {
const previous = document.title;
document.title = unread > 0 ? `(${unread}) Inbox` : "Inbox";
return () => { document.title = previous; }; // восстановить при размонтировании
}, [unread]);
return null;
}Признак того, что это настоящий эффект: цель (document.title) — не состояние React, не пропс, не JSX. Ты тянешься за пределы дерева рендера, чтобы мутировать мир. Это и есть вся лицензия на существование эффекта.
Доминирующий режим отказа: эффект, используемый для вычисления производных данных из пропсов или состояния. Это то злоупотребление, что производит большинство отчётов «мой эффект глючит». Рассуждение кажется верным — «когда меняется items или query, пересчитай filtered» — поэтому люди пишут эффект, который выставляет второе состояние. Но внешней системы здесь нет. filtered — чистая функция от items и query; вычислять её во время рендера правильно, синхронно и без багов. Версия с эффектом вносит лишний рендер, второй источник истины и окно, в котором экран показывает устаревшие данные.
// НЕВЕРНО: внешней системы нет — это производные данные в костюме эффекта
const [filtered, setFiltered] = useState<Item[]>([]);
useEffect(() => {
setFiltered(items.filter((i) => i.name.includes(query)));
}, [items, query]); // лишний рендер + может показать устаревшее
// ВЕРНО: вычисли во время рендера; ни эффекта, ни второго состояния, никогда не устаревает
const filtered = items.filter((i) => i.name.includes(query));
// (оборачивай в useMemo, только если фильтр действительно дорогой)Эвристика: прежде чем писать эффект, назови внешнюю систему, с которой он синхронизируется. «Он синхронизируется с… filtered» — это не внешняя система, это состояние React, и эффект надо удалить.
Живой бейдж «статус онлайн» — сначала ловушка эффекта-как-реакции, затем синхронизация, сделанная правильно. Компонент должен показывать, подключён ли пользователь, где о связности сообщает браузерный navigator.onLine плюс события online/offline — внешняя система.
Частая первая версия мешает настоящую подписку с эффектом производных данных:
function StatusBadge({ user }: { user: { name: string } }) {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [label, setLabel] = useState("");
// эффект №1: НАСТОЯЩАЯ синхронизация с системой связности браузера
useEffect(() => {
const on = () => setIsOnline(true);
const off = () => setIsOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
// эффект №2: НЕВЕРНО — `label` производен от isOnline + user; внешней системы нет
useEffect(() => {
setLabel(`${user.name} is ${isOnline ? "online" : "offline"}`);
}, [isOnline, user.name]);
return <span>{label}</span>;
}Эффект №1 образцов: он подписывается на внешнюю систему и очищается. Эффект №2 — это режим отказа: label — чисто функция от isOnline и user.name, так что эффект добавляет рендер и окно устаревания ни за что. Исправление сохраняет одну настоящую синхронизацию и удаляет фальшивую:
function StatusBadge({ user }: { user: { name: string } }) {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setIsOnline(true);
const off = () => setIsOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
const label = `${user.name} is ${isOnline ? "online" : "offline"}`; // производное в рендере
return <span>{label}</span>;
}Один эффект, потому что внешняя система ровно одна. Текст бейджа вычисляется во время рендера. (В реальном приложении ты бы потянулся за useSyncExternalStore, чтобы подписаться на navigator.onLine — это следующая шлифовка ровно этого паттерна, и она существует именно потому, что подписка на внешний стор настолько частая.)
▸Почему это работает
Почему «не используй эффекты для производных данных» сформулировано так абсолютно, когда эффект технически может это сделать? Потому что каждый эффект-как-реакция — это узел в скрытом графе зависимостей: эффект A выставляет состояние, которое триггерит эффект B, который выставляет состояние, которое триггерит эффект C. Каждое звено добавляет проход рендера, такт устаревшего UI и шанс забыть зависимость. Рендеры React дёшевы и синхронны; эффекты асинхронны и упорядочены после отрисовки. Вычисление производных значений в рендере держит всё в одном синхронном проходе с единым источником истины. Эта дисциплина — не догма, это схлопывание хрупкого многопроходного каскада обратно в один проход.
▸Частая ошибка
Самые коварные случаи прячутся за настоящими внешними системами. «Я делаю запрос в эффекте, а потом в другом эффекте преобразую ответ в состояние отображения». Запрос — законная синхронизация с сетью; эффект-преобразование — та же ошибка с производными данными: преобразуй во время рендера или в самом .then запроса. Именование системы на каждый эффект ловит это: эффект один синхронизируется с сервером; эффект два синхронизируется ни с чем, значит, это не эффект. Один эффект — одна внешняя система. Если единственная задача эффекта — записать состояние React из другого состояния React, это производные данные, и точка.
Ревьюер видит эффект, чьё тело — `setSummary(\`${items.length} items, ${selected.size} selected\`)` с зависимостями `[items, selected]`. И `items`, и `selected` — состояние React. Каков senior-вердикт?
useEffect — это запасной выход для синхронизации React с внешней системой — подпиской, браузерным API вроде document.title, не-React-виджетом, сетевым соединением, — и у корректного эффекта всегда есть парная очистка, которая разбирает ровно то, что создала его настройка. Читай массив зависимостей как «ре-синхронизируй, когда они меняются», а не «реагируй, когда они меняются». Доминирующий режим отказа и источник большинства багов с эффектами — использование эффекта как общего механизма «реагируй на состояние» для вычисления производных данных: это значение — чистая функция от пропсов и состояния, так что ему место в рендере (или в useMemo, если дорого), а никогда — во втором состоянии, записанном эффектом, что лишь покупает лишний рендер, второй источник истины и устаревший UI. Одна эвристика, которая предотвращает всё это: прежде чем писать эффект, назови внешнюю систему, с которой он синхронизируется; если назвать не можешь — эффект тебе не нужен.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.