Как найти ненужные эффекты в React
Используйте eslint-plugin-react-you-might-not-need-an-effect, чтобы находить лишние эффекты React и перерабатывать производное состояние, обработчики событий и сбросы по props.
eslint-plugin-react-you-might-not-need-an-effect — плагин для ESLint и Oxlint. Он находит вызовы useEffect, которые документация React считает излишними: эффекты, вычисляющие производное состояние, пробрасывающие события или передающие данные родительскому компоненту.
Вы прочитали You Might Not Need an Effect и со всем согласились. А потом открыли кодовую базу с четырьмя сотнями вызовов useEffect, и оказалось, что на практике непонятно, какие из них проблемные.
В этой статье мы установим плагин, посмотрим, что он находит в реалистичном компоненте, разберём рефакторинг четырёх самых частых случаев и обсудим, когда предупреждение лучше подавить, чем исправлять. Освежить знания о самом хуке поможет руководство по хуку useEffect в React.
Ключевые выводы
- eslint-plugin-react-you-might-not-need-an-effect содержит девять правил и два пресета для flat config:
recommendedвыводит все правила как предупреждения,strict— как ошибки. Для проектов с устаревшим форматом .eslintrc есть аналогичные пресетыlegacy-recommendedиlegacy-strict. - Значения, которые зависят только от текущих пропсов и состояния, вычисляйте во время рендера, а не копируйте в состояние через эффект.
- Чтобы сбросить всё состояние компонента при изменении пропа, например ID клиента, передайте этот проп компоненту в качестве
key. - Эффекты, которые синхронизируют React с внешней системой (таймером, DOM API, сторонним виджетом или сетевым соединением), — это ровно то, для чего существует
useEffect. Их нужно оставить. - Подавляйте предупреждение комментарием
eslint-disable-next-lineс именем правила и пояснением после--.
Какой эффект в React можно считать ненужным?
Большинство ненужных эффектов встречается в двух видах. Первый — эффект пересчитывает значение и сохраняет его в состояние. Второй — эффект следит за одной частью состояния и подгоняет под неё другую. В обоих случаях React сначала фиксирует рендер со старым значением, затем выполняет эффект и рендерит компонент повторно уже с исправленным значением.
Пользователи замечают этот лишний рендер. В записях пользовательских сессий для компонентов с большим количеством эффектов можно увидеть пустой отфильтрованный список или итоговую сумму-заглушку: они держатся один кадр, пока второй рендер не подставит нужные данные. Почти всегда решение состоит в том, чтобы убрать само состояние, а не донастраивать эффект.
Установка и запуск плагина
Плагин устанавливается как dev-зависимость и подключается к flat config ESLint через пресет recommended. Если вы уже используете несколько плагинов ESLint, он будет работать рядом с ними без конфликтов.
npm install --save-dev eslint-plugin-react-you-might-not-need-an-effect
// eslint.config.js
import reactYouMightNotNeedAnEffect from "eslint-plugin-react-you-might-not-need-an-effect";
import globals from "globals";
export default [
reactYouMightNotNeedAnEffect.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser },
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
];
Примеры рассчитаны на версию 1.0.2, которая, согласно CHANGELOG плагина, вышла 20 августа 2026 года. Там же указано, что правила no-pass-ref-to-parent и no-empty-effect были удалены ещё до версии 1.0, поэтому уберите их из старых конфигураций. В Oxlint плагин подключается через поле jsPlugins. Идентификаторы правил используют префикс в kebab-case react-you-might-not-need-an-effect/ в трёх случаях: в Oxlint, в устаревших конфигурациях и во flat config при использовании пресетов, поскольку пресеты регистрируют плагин под именем его пакета. Префикс в camelCase reactYouMightNotNeedAnEffect/ нужен только в том случае, если вы сами зарегистрировали плагин под этим ключом, как в примере с отдельным правилом из README.
Вот компонент с четырьмя эффектами. Каждый из них написан по шаблону, описанному в документации одного из правил:
import { useEffect, useState } from "react";
import { postOrder, useCustomerHistory } from "./api";
export function OrderPanel({ customerId, items, onLoaded }) {
const [total, setTotal] = useState(0);
const [submitted, setSubmitted] = useState(false);
const [note, setNote] = useState("");
const history = useCustomerHistory(customerId);
// Effect 1
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
// Effect 2
useEffect(() => {
if (submitted) postOrder(customerId, items, note);
}, [submitted, customerId, items, note]);
// Effect 3
useEffect(() => {
onLoaded(history);
}, [history, onLoaded]);
// Effect 4
useEffect(() => {
setNote("");
setSubmitted(false);
}, [customerId]);
return (
<form onSubmit={(e) => { e.preventDefault(); setSubmitted(true); }}>
<textarea value={note} onChange={(e) => setNote(e.target.value)} />
<p>Total: {total}</p>
<button type="submit">Place order</button>
</form>
);
}
Запустите npx eslint src/OrderPanel.jsx. С пресетом recommended замечания выводятся как предупреждения, а в конце каждой строки указан идентификатор правила. Каждый эффект срабатывает на своё правило:
| Эффект | Шаблон | Правило |
|---|---|---|
| 1 | Состояние, вычисленное из пропсов | no-derived-state |
| 2 | Флаг состояния в роли триггера события | no-event-handler |
| 3 | Загруженные данные передаются в колбэк родителя | no-pass-data-to-parent |
| 4 | Состояние сбрасывается при изменении пропа | no-adjust-state-on-prop-change (по предупреждению на каждый сеттер) |
Эффект 4 — именно тот случай, который решается с помощью key. В более простых компонентах такую конструкцию находит no-reset-all-state-on-prop-change. Но в компоненте вроде этого, где состояние читается во время рендера, версия 1.0.2 сообщает о каждом сбросе через правило no-adjust-state-on-prop-change.
Полный набор правил и рекомендуемая замена для каждого:
| Правило | Чем заменить эффект |
|---|---|
no-derived-state | Значением, вычисляемым во время рендера |
no-chain-state-updates | Одним объединённым обновлением состояния |
no-event-handler | Кодом в обработчике события |
no-adjust-state-on-prop-change | Реструктуризацией состояния или вычислением во время рендера |
no-reset-all-state-on-prop-change | Передачей пропа в качестве key |
no-pass-live-state-to-parent | Поднятием состояния вверх или возвратом значения из хука |
no-pass-data-to-parent | Загрузкой данных в родителе с передачей результата через пропсы |
no-external-store-subscription | useSyncExternalStore |
no-initialize-state | Начальным значением в useState() |
Плагин отслеживает цепочки вызовов и массивы зависимостей, поэтому линтинг становится тяжелее. Насколько именно в вашем репозитории, покажет профилирование правил ESLint (TIMING=1). README также советует включить react-hooks/exhaustive-deps, поскольку плагин исходит из того, что массивы зависимостей корректны, и typescript-eslint/no-floating-promises, которое помогает ему распознавать вызовы асинхронных функций.
Производное состояние: вычисляйте во время рендера
Если значение можно получить из уже имеющихся пропсов и состояния, вычисляйте его во время рендера. Не храните копию в состоянии и не синхронизируйте её эффектом. В документации React этому посвящён раздел об обновлении состояния на основе пропсов или состояния.
// Before
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
// After
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
Теперь сумма корректна уже при первом рендере. Если вычисление окажется затратным, оберните его в useMemo, но не возвращайте в состояние.
Логика обработчика событий: перенесите её в обработчик
Эффект, который ждёт флага состояния, выставленного в обработчике отправки формы или клика, на самом деле является замаскированным обработчиком события. Выполняйте эту логику прямо в обработчике. Обоснование приведено в статье Separating Events from Effects.
// Before
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) postOrder(customerId, items, note);
}, [submitted, customerId, items, note]);
// After
function handleSubmit(e) {
e.preventDefault();
postOrder(customerId, items, note);
}
В исходной версии есть реальный баг: если отредактировать примечание после отправки, заказ отправится повторно. Начиная с версии 0.10.1, no-event-handler также находит пропсы, используемые таким образом, а с версии 0.10.2 сообщает о проблеме в месте использования, а не в месте объявления.
Загрузка данных и передача их наверх
Если дочерний компонент загружает данные и передаёт их родителю через эффект, эту задачу стоит отдать родителю, механизму загрузки данных фреймворка или библиотеке для работы с данными, а результат получать через пропсы. React описывает такую же инверсию в разделе о передаче данных родителю.
// Before: child fetches, then pushes up
const history = useCustomerHistory(customerId);
useEffect(() => {
onLoaded(history);
}, [history, onLoaded]);
// After: parent owns the data
function OrderPage({ customerId, items }) {
const history = useCustomerHistory(customerId); // loader, library, or parent effect
return <OrderPanel customerId={customerId} items={items} history={history} />;
}
Когда данные движутся в одном направлении, сразу понятно, какой компонент ими владеет. Кроме того, OrderPanel больше не нужен колбэк-проп onLoaded, который служил лишь для того, чтобы сообщать о загруженных данных.
Сброс состояния при изменении пропа: используйте key
Чтобы сбросить всё состояние компонента при изменении пропа, например ID клиента, передайте этот проп компоненту в качестве key, и React смонтирует новый экземпляр. Этот подход описан в разделе о сбросе всего состояния при изменении пропа.
// Before: Effect 4 inside OrderPanel
useEffect(() => {
setNote("");
setSubmitted(false);
}, [customerId]);
// After: delete the effect; in the parent
<OrderPanel key={customerId} customerId={customerId} items={items} history={history} />
Правило no-adjust-state-on-prop-change рассчитано на частичный вариант этой ситуации, когда в ответ на изменение пропа меняется лишь часть состояния. Тем не менее, как показал запуск линтера выше, оно срабатывает и на эффект 4. Если сбросить нужно только часть состояния, обычно стоит реструктурировать состояние или вычислять значение во время рендера.
Ложные срабатывания и эффекты, которые нужно оставить
Эффекты, связывающие компонент с тем, чем React не управляет (таймерами, DOM API, сторонними виджетами, сетевыми соединениями), — это ровно то, для чего создавался useEffect, поэтому их стоит оставить. Большинству из них нужна функция очистки, как объясняется в статье Synchronizing with Effects. Если измерение DOM вызывает заметное мерцание, важнее выбор между useEffect и useLayoutEffect, чем вопрос, оставлять ли эффект вообще.
Исключение из правила «эффекты синхронизации остаются» — подписки на внешнее хранилище. Для них предназначен useSyncExternalStore: он читает снимок данных во время рендера, а не копирует его в состояние из эффекта. Вариант на основе эффекта находит правило no-external-store-subscription.
У статического анализа эффектов есть пограничные случаи. Собственное правило React set-state-in-effect предупреждает, когда эффект синхронно вызывает сеттер состояния. При этом в issue о строгости set-state-in-effect оспаривается, насколько жёстко оно должно относиться к флагам монтирования и шаблонам определения клиентской среды. Воспринимайте предупреждения на таких конструкциях как повод проверить код, а не как окончательный вердикт. Использовать оба плагина одновременно вполне разумно: правило React покрывает вызовы setState в эффектах, а этот плагин добавляет проверку колбэков родителя, цепочек обновлений и подписок на хранилища.
Прежде чем подавлять предупреждение, ответьте на вопросы:
- Взаимодействует ли эффект с системой за пределами React?
- Нужна ли ему очистка при изменении зависимостей или размонтировании компонента?
- Изменится ли видимое пользователю поведение, если перенести логику в рендер или обработчик?
Если ответы оправдывают сохранение эффекта, отключите правило для этой строки и с помощью синтаксиса описаний ESLint с -- зафиксируйте причину:
// eslint-disable-next-line react-you-might-not-need-an-effect/no-initialize-state -- client-only value, avoids hydration mismatch
Этот префикс соответствует пресетам, Oxlint и устаревшим конфигурациям. reactYouMightNotNeedAnEffect/ используйте только в том случае, если сами зарегистрировали плагин под этим ключом.
Внедрение
Начните внедрение eslint-plugin-react-you-might-not-need-an-effect с пресета recommended: замечания будут выводиться как предупреждения, и CI продолжит проходить. Разбирайте их по одному компоненту, исправляя код или добавляя пояснения. Когда число предупреждений дойдёт до нуля, переключитесь на strict, чтобы новые ненужные эффекты ломали сборку.
Часто задаваемые вопросы
Можно ли использовать eslint-plugin-react-you-might-not-need-an-effect с устаревшей конфигурацией .eslintrc?
Да. В проектах с устаревшим форматом .eslintrc расширьте конфигурацию пресетом plugin:react-you-might-not-need-an-effect/legacy-recommended, чтобы все правила выводились как предупреждения, или plugin:react-you-might-not-need-an-effect/legacy-strict, чтобы они выводились как ошибки. В устаревших конфигурациях идентификаторы правил и комментарии для их отключения используют префикс в kebab-case react-you-might-not-need-an-effect/ — тот же, что и пресеты flat config.
Когда стоит оборачивать производное значение в useMemo, а не вычислять его при каждом рендере?
Используйте useMemo, только если вычисление ощутимо медленное. Документация React советует замерить время с помощью console.time и мемоизировать, если оно составляет примерно 1 мс или больше. useMemo кэширует результат между рендерами, не перенося его в состояние, поэтому значение остаётся корректным уже при первом рендере. React Compiler умеет автоматически мемоизировать многие вычисления, поэтому ручные вызовы useMemo для производных значений нужны всё реже.
Сбрасывается ли при изменении key компонента также состояние его дочерних компонентов?
Да. При изменении key React размонтирует компонент вместе со всем его поддеревом, а затем монтирует новые экземпляры. Все дочерние компоненты теряют локальное состояние, DOM-узлы создаются заново, а все эффекты выполняют очистку и запускаются повторно. Если нужно сбросить только часть состояния, сохранив состояние дочерних компонентов, измените это состояние напрямую или реструктурируйте его вместо использования key.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k