Когда эффект не нужен
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Эффект — самый соблазнительный хук: «выполни этот код, когда что-то изменилось». В него удобно складывать всё подряд — пересчёт суммы, копию пропса, отправку формы. Каждый такой эффект работает, и каждый стоит лишней отрисовки, а между двумя отрисовками в DOM успевает побывать кадр с данными, которые не сходятся. Все три случая узнаются по одному вопросу: почему этот код должен выполниться?
В статье о структуре состояния сумма корзины считалась в теле компонента, а отдельное состояние под неё было признано лишним. Эффект возвращает соблазн в новом обличье: «когда изменится count — пересчитай total». Строка в теле компонента печатает каждую отрисовку, строка в эффекте читает то, что в этот момент стоит в DOM. Предскажите: сколько отрисовок сделает один клик и какую сумму эффект увидит на экране.
Первая отрисовка — с нулевой суммой: useState(0) ничего не знает про count. Эффект запускается уже после коммита — это его природа из статьи о синхронизации, — и в DOM он находит «к оплате: 0». Кадр, в котором количество и сумма не сходятся, уже записан. Затем setTotal подаёт заявку, и React рисует компонент второй раз. Клик повторяет всё сначала: отрисовка с новым количеством и старой суммой, эффект, ещё одна отрисовка. Каждое изменение стоит двух отрисовок, и между ними в DOM живёт состояние, которого в вашей модели данных никогда не было.
Эффект видит старую сумму не потому, что «не успел», а по построению: он запускается после коммита, а коммит уже записал кадр.
Браузер этот кадр иногда успевает нарисовать — тогда сумма на мгновение мигает старым числом. Чаще не успевает, и ошибка остаётся невидимой: код «работает», просто делает вдвое больше работы и держит лишнее состояние, которое в любой момент может разойтись с источником — ровно как found из статьи о структуре состояния.
Лечится вычёркиванием: и состояние, и эффект. Сумма — обычная константа тела компонента.
Одна отрисовка на клик, ни одного кадра с расхождением, и код короче на пять строк. Правило из статьи о структуре состояния получает продолжение: производное значение не только не хранят в состоянии — его и не «поддерживают» эффектом. Тело компонента и так выполняется на каждой отрисовке; эффект добавляет к этому только задержку. А вызвать setTotal прямо в теле нельзя: это бесконечный цикл, вы видели его в статье про рендер и коммит.
В статье о структуре состояния компонент Copied переписал пропс в useState и замер: хук читает аргумент только при первой отрисовке. Первое, что приходит в голову после такого открытия, — «досинхронизировать» эффектом: изменился пропс — положить его в состояние. Родитель ниже меняет оттенок по кнопке. Сколько раз отрисуется Synced после клика и что напечатает первая из этих отрисовок?
Работает: абзац красный. Но у клика две отрисовки Synced, и в первой tone уже «красный», а color ещё «серый». Этот кадр закоммичен — эффект запустился после него. На одну отрисовку компонент показывает значение, которого родитель уже не передаёт. Дальше как в первом разделе: заявка, вторая отрисовка — и всё это ради значения, которое компонент и так получил аргументом.
Здесь нет даже производного значения: color в точности равен tone, только с опозданием. Правильный вариант известен со статьи о структуре состояния — брать пропс из аргументов: return <p>цвет: {tone}</p>. Ни состояния, ни эффекта, одна отрисовка.
Остаётся настоящий случай: компоненту нужна своя, редактируемая копия — поле с черновиком, которое пользователь правит, но при переходе к другой записи оно должно начаться заново. Эффект «сбросить при смене id» и тут не нужен: у React для этого есть key — компонент с новым ключом начинает жизнь заново вместе со своим useState. Этот приём разбирает статья «Когда состояние сохраняется, а когда сбрасывается» в следующей главе; пока запомните, что «сбросить состояние при смене пропса» — задача для ключа, а не для useEffect.
| Что хочется | Как это делают эффектом | Как без эффекта |
|---|---|---|
| Показать пропс | useState(tone) + useEffect(() => setColor(tone), [tone]) | tone прямо в разметке |
| Вычислить из пропсов | useEffect(() => setFull(first + last), [first, last]) | const full = first + " " + last |
| Сбросить своё состояние при смене записи | useEffect(() => setDraft(""), [id]) | key={id} на компоненте — следующая глава |
| Выбранный элемент, когда список сменился | useEffect(() => setSelected(null), [items]) | хранить id, искать в теле: items.find(...) |
Третья ошибка коварнее: она не про лишнюю отрисовку, а про смысл. Нужно показать уведомление, когда пользователь добавил товар. Список товаров — состояние, эффект следит за списком, — казалось бы, ровно то, что нужно. Пример нажимает «добавить кофе», потом «убрать последний». Сколько раз напечатается уведомление?
Три уведомления на одно добавление. Первое — при монтировании: в корзине с самого начала лежит чай, и эффект запустился, как запускается любой эффект после первой отрисовки. Третье — после удаления: список стал другим массивом, зависимость сменилась, эффект отработал. Он не умеет отличить «добавили» от «убрали» — он видит только, что items теперь не тот, что был.
В части II такой ошибки не могло быть по построению: код, который должен выполниться по нажатию, жил в обработчике нажатия, а render() только рисовал. Эффект — часть отрисовки, отложенная до коммита. Он отвечает на вопрос «компонент показан с такими данными», а не «пользователь что-то сделал». Ответ на второй вопрос знает только обработчик.
Одно уведомление, ровно там, где произошло действие. Заодно обработчик знает то, чего эффект не узнает никогда: какой именно товар добавили, из какой кнопки, с каким событием. Если нужно и добавить, и уведомить, и отправить на сервер — это последовательные строки одного обработчика, а не три эффекта, ловящие следствия друг друга.
Проверка: код должен выполниться, потому что пользователь нажал, ввёл или отправил — это обработчик. Потому что компонент показан на экране — эффект.
Частый обходной путь: обработчик ставит флаг, эффект на него срабатывает и сбрасывает. Посчитайте отрисовки на одно нажатие.
Клик — отрисовка с флагом — эффект — сброс флага — ещё отрисовка: две отрисовки и отправка, отложенная до коммита, вместо одной строки send() в обработчике, после которой отрисовок не было бы вовсе. Флаг здесь — событие, переодетое в состояние, и все признаки налицо: в состоянии лежит то, что на экране не показывается, а эффект первым делом это состояние сбрасывает.
Когда каждое состояние поддерживает свой эффект, эффекты начинают цеплять друг друга: первый меняет состояние, от которого зависит второй, второй — третье. В игре ниже за очки растёт уровень, за уровень — бонус. Сколько отрисовок сделает один клик по «+10»? Не забудьте и про монтирование.
Пять отрисовок: две на монтирование и три на клик. Первый эффект при монтировании ничего не изменил — setLevel(1) при уровне 1 сеттер отбросил, вы видели это сравнение в статье про рендер и коммит, — а вот бонус с нуля до ста дал вторую отрисовку. Клик же проходит цепочку целиком: очки 10 при уровне 1, уровень 2 при бонусе 100 и только потом согласованный кадр. Каждый эффект честно синхронизирует своё, но по очереди, и каждая очередь — отдельный проход рендера и коммита.
Здесь всё производное: уровень считается из очков, бонус — из уровня. Обе строки переезжают в тело, и цепочка исчезает вместе с двумя состояниями.
Бывает, что звено цепочки из предыдущего не выводится: уровень выбирает сам игрок, а бонус зависит ещё и от того, сколько раз он до этого проигрывал. Тогда состояние настоящее, но и эффект ему не нужен — все связанные значения считают в обработчике и ставят одним набором вызовов сеттеров. React соберёт их в одну отрисовку, как в статье про очередь обновлений, и промежуточных кадров не будет.
Цепочка эффектов — почти всегда цепочка производных значений, которые по ошибке стали состояниями. Разматывать её нужно с конца: что из этого можно посчитать?
Все случаи выше различаются одним вопросом: почему этот код должен выполниться? Ответов три, и каждому соответствует своё место в компоненте.
| Почему код должен выполниться | Где ему место | Примеры |
|---|---|---|
| Результат можно посчитать из состояния и пропсов | тело компонента, обычная константа | сумма корзины, отфильтрованный список, уровень из очков |
| Пользователь что-то сделал | обработчик события | уведомление, отправка формы, добавление в корзину |
| Компонент показан на экране, и мир за пределами React должен это учесть | useEffect | подписка на resize, виджет без React, таймер, запрос при показе |
Эффекту остаётся то, ради чего он придуман: внешний мир. Подписка на событие window, сторонний виджет, которому нужно передать новые параметры, запрос при показе — всё, что не выражается ни возвращаемой разметкой, ни реакцией на клик. Про каждый из этих случаев вы читали в трёх предыдущих статьях; про запросы в Next.js есть ещё один ответ — сделать их на сервере, до всякого useEffect, но это уже часть про серверные компоненты.
Есть и пограничный случай, который выглядит как работа для эффекта, но им не является: прочитать начальное значение из хранилища. Эффект «при монтировании» сделает это после первой отрисовки, и первый кадр уйдёт пустым — тот же лишний кадр, что в первом разделе. Сравните два компонента: оба читают черновик, у одного на это эффект, у другого — ленивый инициализатор из статьи про useState.
ViaEffect отрисовался дважды, и в первый раз — с пустой строкой; ViaInit — один раз и сразу с текстом. Код, который должен выполниться один раз на всё приложение — проверить сессию, настроить библиотеку, — тоже не эффект: компонент может смонтироваться несколько раз, а модуль выполняется один. Такому коду место на уровне модуля, рядом с createRoot.
Практический признак: если в эффекте нет ничего, кроме вызовов сеттеров, — он почти наверняка лишний. Настоящий эффект что-то делает с миром за пределами React.
- Производное значение считают в теле компонента, а не поддерживают эффектом: эффект даёт лишнюю отрисовку и кадр, где данные не сходятся.
- Пропс читают из аргументов; useState(prop) плюс useEffect с сеттером — два состояния вместо нуля. Сброс своего состояния при смене записи — задача для key.
- Код, который должен выполниться из-за действия пользователя, живёт в обработчике: эффект не знает, почему изменились данные, и сработает при монтировании и при любой их смене.
- Цепочка эффектов, где каждый ставит состояние для следующего, — несколько отрисовок на одно действие; считайте всё сразу в теле или в обработчике.
- Начальное значение из хранилища — ленивый инициализатор useState(() => ...), код «один раз на приложение» — уровень модуля.
- Эффекту остаётся внешний мир: подписки, сторонние виджеты, запросы при показе. Если в эффекте одни сеттеры — он почти наверняка лишний.
1/4Компонент хранит total в useState и пересчитывает его в useEffect при изменении count. Сколько отрисовок вызовет один клик по «+1»?
2/4В компоненте написано useEffect(() => { if (items.length > 0) notify(); }, [items]). Когда сработает notify?
3/4Компонент копирует пропс tone в useState и синхронизирует его эффектом useEffect(() => setColor(tone), [tone]). Что с этим не так?
4/4Какой из этих случаев — работа для useEffect?
Поиск, который отстаёт на отрисовку
Результат поиска хранится в состоянии и пересчитывается эффектом. Сейчас пример печатает четыре отрисовки, и в двух из них список не соответствует запросу. Перепишите компонент так, чтобы отрисовок стало две, а лишнее состояние и эффект исчезли.
Результат поиска целиком выводится из ITEMS и query — это производное значение, и ему место в теле компонента.
Две отрисовки: первая сразу с четырьмя товарами, вторая — с тремя после клика. Кадров, где запрос уже «ка», а в списке ещё всё подряд, больше нет. Тип found компилятор выводит сам — string[], — и объявлять его явно не нужно.
Уведомление по делу
Уведомление «задача добавлена» должно появляться только при добавлении. Сейчас оно печатается три раза, хотя добавление было одно. Исправьте, не добавляя ни флагов, ни новых состояний.
Уведомление — следствие действия пользователя, а не факта «список изменился». Эффект убираем, строку переносим в обработчик кнопки «добавить».
Уведомление одно, и оно знает, какую именно задачу добавили. При монтировании и при очистке ничего не печатается: эти события обработчику add просто не видны, и это правильно.
Выбор, который сбрасывает эффект
Эффект сбрасывает выбор при любой смене списка. В примере пользователь выбирает кофе, потом удаляет чай — и выбор пропадает, хотя кофе никуда не делся. Нужно, чтобы выбор исчезал только вместе с выбранной записью, и без эффекта. Подсказка — в статье о структуре состояния, раздел про запись в двух местах.
В состоянии храним не запись, а её id; саму запись ищем в теле компонента. Тогда выбор «устаревает» сам, когда записи больше нет, и никакого сброса по эффекту не нужно.
После удаления чая на экране по-прежнему «выбрано: кофе», а если удалить кофе — find вернёт undefined, и выбор станет пустым. Заодно исчез кадр, в котором список уже новый, а выбор ещё старый: у эффекта он был перед сбросом.