Возможно, эффект тебе не нужен
Большинство эффектов в реальном коде не нужны и багованы. Каталог эффектов на удаление — производное состояние, преобразования для отображения, сбросы по пропсам, кэширование, уведомление родителя, цепочки апдейтов — и один эффект, который нельзя удалять по ошибке.
Открой почти любой React-проект — и найдёшь эффекты, существующие лишь затем, чтобы держать один кусок состояния в синхроне с другим. useEffect, который пересчитывает отфильтрованный список при смене запроса. Тот, что сбрасывает форму при смене выбранного пользователя. Тот, что вызывает onChange после того, как апдейт состояния зафиксировался. Каждый по отдельности выглядит разумно, и каждый — баг, готовый выстрелить: лишний рендер, протухшее замыкание, вспышка неверных данных, синхронизация, отстающая на один тик.
Senior-ход — не писать лучшие эффекты. А удалять их. Эффект — это синхронизация с системой вне React; если внешней системы нет, эффект почти наверняка заново выводит данные, которые у тебя уже есть, а выводить их заново в эффекте — медленный и гоночный способ сделать то, что рендер делает бесплатно. Этот урок — каталог эффектов на удаление, и одного, который удалять нельзя.
После этого урока ты можешь распознать шесть самых частых паттернов ненужных эффектов — производное состояние, преобразование данных для отображения, сброс состояния при смене пропса, кэширование дорогих результатов, уведомление родителя и цепочки апдейтов состояния — и убрать каждый правильным инструментом (вычисление во время рендера, key, useMemo, обработчик события). А ещё ты можешь отличить их от настоящего эффекта внешней синхронизации, чтобы удалить багованные, не удалив тот, что реально делает свою работу.
Если это можно вычислить из пропсов и состояния во время рендера — это не состояние, и в эффекте ему не место. Классический анти-паттерн хранит производное значение в useState и держит его актуальным эффектом. Это два рендера на каждое изменение (поставить источник, эффект сработал, поставить производное, рендер снова), окно, где эти двое расходятся, и целый массив зависимостей, который легко испортить. Исправление — вычислять инлайн прямо при рендере.
// ❌ производное состояние, синхронизированное эффектом: лишний рендер, может протухнуть
function FullName({ first, last }: { first: string; last: string }) {
const [full, setFull] = useState("");
useEffect(() => { setFull(`${first} ${last}`); }, [first, last]);
return <h2>{full}</h2>;
}
// ✅ просто вычисли это — всегда согласованно, один рендер
function FullName({ first, last }: { first: string; last: string }) {
const full = `${first} ${last}`;
return <h2>{full}</h2>;
}Рендер и есть синхронизация. Каждый раз, когда ловишь себя на написании setSomething внутри эффекта, чьи единственные входы — другие пропсы/состояние, тебе почти наверняка нужен обычный const.
Преобразование данных для отображения — тот же анти-паттерн, но со списком вместо строки, и с тем же исправлением. Фильтрация, сортировка, маппинг загруженного массива во view-модели: ничто из этого не трогает внешнюю систему, значит, ничто не место в эффекте. Вычисляй в рендере. Если — и только если — профилирование показывает, что преобразование действительно дорогое и выполняется на каждый несвязанный рендер, оберни его в useMemo. useMemo — это кэш времени рендера, а не эффект; он никогда не вызывает двойной-рендер-затем-вспышку, как версия с эффектом.
// ❌ эффект зеркалит производный список в состояние
const [visible, setVisible] = useState<Todo[]>([]);
useEffect(() => {
setVisible(todos.filter((t) => t.status === filter));
}, [todos, filter]);
// ✅ выводи в рендере; мемоизируй, только если профилирование скажет, что фильтр горячий
const visible = useMemo(
() => todos.filter((t) => t.status === filter),
[todos, filter],
);Тянись за useMemo, потому что так сказал замер, а не рефлекторно. Немемоизированный .filter() по паре сотен строк — нормально, а useMemo повсюду — просто шум, скрывающий тот единственный, который важен.
Чтобы сбросить состояние при смене пропса, дай компоненту key — не сбрасывай его эффектом. Частая форма: редактор профиля держит черновик состояния, а эффект очищает черновик всякий раз при смене userId. Этот эффект рендерит черновик старого пользователя один кадр, прежде чем сброс зафиксируется, и ему приходится перечислять каждый кусок состояния для очистки. У React уже есть примитив для «теперь это концептуально другой экземпляр»: смени key, и React размонтирует старое поддерево и смонтирует свежее с начальным состоянием. Ни эффекта, ни протухшего кадра, ни перечисления.
// ❌ эффект сбрасывает локальное состояние при смене идентичности — вспышка старого черновика
function Editor({ userId }: { userId: string }) {
const [draft, setDraft] = useState("");
useEffect(() => { setDraft(""); }, [userId]); // ещё: забыли про остальные 3 поля
/* ... */
}
// ✅ key заставляет React перемонтировать свежий экземпляр — всё состояние сбрасывается атомарно
function EditorBoundary({ userId }: { userId: string }) {
return <Editor key={userId} userId={userId} />;
}
function Editor({ userId }: { userId: string }) {
const [draft, setDraft] = useState(""); // стартует чистым на каждый userId, без эффекта
/* ... */
}key живёт уровнем выше, на родителе, который решает идентичность. Это самый высокорычажный эффект на удаление: он убирает целый класс багов «почему форма показывает данные предыдущей записи».
Уведомляй родителя, запускай сайд-эффекты и связывай апдейты внутри обработчика события — не в эффекте, следящем за состоянием. Здесь живут два родственных запаха. Первый — уведомление родителя: эффект, вызывающий onChange(value) после изменения value, срабатывает на любую причину изменения (включая пришедшие от родителя) и опаздывает на рендер. Помести вызов в обработчик, вызвавший изменение, где новое значение тебе уже известно. Второй — цепочки апдейтов: установка состояния A в эффекте, следящем за состоянием B, которое ставит C в эффекте, следящем за A, — это каскад лишних проходов рендера, который невозможно отследить. Вычисли всё следующее состояние в обработчике и поставь его в одном месте.
// ❌ эффект зеркалит изменение наверх к родителю — лишний рендер, срабатывает на любом пути
function Toggle({ onChange }: { onChange: (on: boolean) => void }) {
const [on, setOn] = useState(false);
useEffect(() => { onChange(on); }, [on]); // да и onChange даже нет в зависимостях
return <button onClick={() => setOn((v) => !v)}>{on ? "On" : "Off"}</button>;
}
// ✅ делай обе вещи в одном месте, где есть новое значение: в обработчике
function Toggle({ onChange }: { onChange: (on: boolean) => void }) {
const [on, setOn] = useState(false);
function handleClick() {
const next = !on;
setOn(next);
onChange(next); // родитель узнаёт сразу, в том же событии
}
return <button onClick={handleClick}>{on ? "On" : "Off"}</button>;
}Обработчики событий запускаются, потому что пользователь что-то сделал; эффекты запускаются после рендера, по любой причине. Когда причина — конкретное взаимодействие, обработчик — правильный дом: он раньше, у него новое значение под рукой, и он не сработает на несвязанных перерисовках.
Компонент результатов поиска, до и после чистки. Он загружает результаты по запросу, отдаёт родителю «число результатов» и сбрасывает скролл/выбор при смене запроса. Первая версия выражает все три через эффекты.
// ❌ до: три эффекта, два из них ненужны и багованы
function Results({ query, onCount }: { query: string; onCount: (n: number) => void }) {
const [items, setItems] = useState<Item[]>([]);
const [sorted, setSorted] = useState<Item[]>([]);
const [selected, setSelected] = useState<string | null>(null);
// (1) настоящий: загрузка с сервера при смене запроса
useEffect(() => {
let active = true;
fetchResults(query).then((r) => { if (active) setItems(r); });
return () => { active = false; };
}, [query]);
// (2) ненужный: производный список, зеркалённый в состояние
useEffect(() => { setSorted([...items].sort(byScore)); }, [items]);
// (3) ненужный: уведомить родителя + сбросить выбор из эффекта
useEffect(() => { onCount(sorted.length); }, [sorted]);
useEffect(() => { setSelected(null); }, [query]); // вспышка старого выбора
return <List items={sorted} selected={selected} onPick={setSelected} />;
}Эффект (2) выводим, уведомление эффекта (3) принадлежит туда, куда приходят данные, а сброс выбора принадлежит идентичности — key. После чистки остаётся ровно один эффект, и это единственный, кто трогает внешний мир.
// ✅ после: один настоящий эффект; остальное становится рендером + key
function Results({ query, onCount }: { query: string; onCount: (n: number) => void }) {
const [items, setItems] = useState<Item[]>([]);
useEffect(() => { // ЕДИНСТВЕННЫЙ законный эффект: синхрон с сервером
let active = true;
fetchResults(query).then((r) => {
if (!active) return;
setItems(r);
onCount(r.length); // уведомляем ровно тогда, когда приходят данные
});
return () => { active = false; };
}, [query]);
const sorted = useMemo(() => [...items].sort(byScore), [items]); // выводим в рендере
return <List items={sorted} onPick={() => {}} />; // сброс выбора обрабатывается через key
}
// выбор сбрасывается на каждый query, потому что меняется идентичность → свежий экземпляр
<Results key={query} query={query} onCount={setCount} />;Два эффекта удалены, один баг с протухшим кадром и один баг со срабатыванием на каждый рендер исчезли, а оставшийся эффект читается ровно как то, чем он является: «синхронизируйся с сервером при смене запроса». Эта читаемость и есть настоящая награда — следующий читатель может доверять, что эффект в этом файле означает настоящую внешнюю границу.
▸Частая ошибка
Опасная перекоррекция — удалить настоящий эффект внешней синхронизации, потому что он поверхностно похож на выводимое состояние. Подписка на стор, навешивание DOM-слушателя события, запуск таймера, открытие WebSocket или императивная синхронизация не-React-виджета (карта, чарт, видеоплеер) — это подлинные эффекты: данные живут вне React, и их надо втянуть и потом снести. Признак — функция очистки: если работе нужна разборка (отписаться, clearInterval, закрыть сокет), это синхронизация, а не вывод, и переписывания через const/useMemo/key тут не применимы. Удаляй эффекты, заново выводящие то, что может вычислить рендер; оставляй эффекты, дотягивающиеся за пределы мира React.
▸Почему это работает
Почему версия с эффектом не просто многословнее, а реально багованнее? Потому что эффект запускается после того, как рендер зафиксирован. Так что всегда есть хотя бы один отрисованный кадр, где состояние-источник обновилось, а производное — нет: вспышка протухшего кадра. Есть двойной рендер (поставить источник → коммит → эффект → поставить производное → коммит снова), удваивающий работу на горячем пути. И есть массив зависимостей, который молча протухает, когда забываешь зависимость (вроде onChange), и выдаёт неверные значения без всякой ошибки. Вычисление в рендере обходит все три: нет «после», нет второго коммита и нет списка зависимостей, который мог бы рассинхронизироваться.
Компонент подписывается на браузерное событие online/offline через addEventListener в useEffect, хранит статус в состоянии и снимает слушателя в очистке. Ревьюер говорит: «возможно, эффект тебе не нужен — выведи его в рендере». Кто прав?
Большинство эффектов в реальном коде не нужны, а ненужные эффекты — это не просто мусор: они вызывают вспышки протухших кадров, двойные рендеры и баги протухших замыканий. Каталог на удаление: производное состояние и преобразования для отображения (вычисляй в рендере; тянись за useMemo, только когда требует профилирование), сброс состояния при смене пропса (дай поддереву key, чтобы React перемонтировал свежий экземпляр), уведомление родителя или цепочки апдейтов (делай это в обработчике события, у которого уже есть новое значение). Единственный вопрос, который их сортирует: общается ли этот эффект с системой вне React? Если нет — он заново выводит данные, и не-эффектный инструмент делает это раньше и надёжнее. Если да — сеть, DOM, подписки, таймеры, не-React-виджеты, что угодно с настоящей разборкой — это синхронизация, и ты её оставляешь. Senior-навык — держать оба края: агрессивно удалять выводимые эффекты и никогда не принимать подлинный эффект внешней синхронизации за один из них.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.