Нужен ли здесь `useEffect`? 12 сценариев из React-код-ревью — от производного состояния до React 19…
- воскресенье, 5 июля 2026 г. в 00:00:08

На code review я регулярно встречаю один и тот же вопрос, только записанный разным кодом: «Как правильно синхронизировать эти значения через useEffect?» Часто полезнее спросить иначе:
А эффект здесь вообще нужен?
Я больше семи лет занимаюсь frontend-разработкой, последние два с лишним года руковожу кросс-функциональной командой, а раньше преподавал React. Со временем повторяющиеся замечания на review сложились для меня в простой фильтр: сначала определить причину выполнения кода, а уже потом выбирать React API.
Эта статья — разбор такого фильтра на 12 сценариях. Для каждого есть вариант «плохо → хорошо» и работающий пример на React 19.2 + TypeScript. Мы разберём производное состояние, пользовательские события, внешние системы, useSyncExternalStore, useEffectEvent и четыре подхода к загрузке данных.
Главная мысль:
useEffectнужен для синхронизации React с внешней системой, а не как универсальный способ запускать код после рендера.
Материал опирается на канонический гайд React You Might Not Need an Effect и документацию Synchronizing with Effects. Здесь я дополняю официальную модель наблюдениями из code review, командным чек-листом, примерами для React 19.2 и проверяемым playground.
У кода в React есть три основных места выполнения.
Рендер — для чистых вычислений из текущих props и state.
Обработчик события или Action — для логики, причиной которой стало действие пользователя.
Эффект — для синхронизации с системой, жизненным циклом которой React не управляет.
Рендер должен оставаться чистым: одинаковые входные данные дают одинаковый JSX, без запросов, подписок и изменения внешнего состояния. Обработчик знает, какое действие совершил пользователь. Эффект этого не знает — он видит только, что компонент был добавлен на страницу или изменились его реактивные зависимости.
Перед новым useEffect я задаю два вопроса:
Можно ли вычислить это значение из уже имеющихся данных? Тогда вычисляю его во время рендера.
Почему должен выполниться этот код? Если из-за клика, ввода или отправки формы — помещаю его в обработчик или Action. Если из-за необходимости поддерживать соединение с внешней системой — использую эффект.

В React важно сначала определить причину выполнения кода: вычисление, действие пользователя или синхронизация с внешней системой.
Ниже не дословный фрагмент одного рабочего PR, а обезличенный собирательный кейс из конструкций, которые я встречал в enterprise-проектах.
В компоненте оформления заявки было четыре эффекта:
useEffect(() => setTotal(calculateTotal(items)), [items]) useEffect(() => shouldSubmit && createOrder(order), [shouldSubmit, order]) useEffect(() => onChange(formState), [formState, onChange]) useEffect(() => fetchOptions(query).then(setOptions), [query])
Каждый эффект по отдельности выглядел объяснимо. Вместе они создавали второй источник правды, сигнальное состояние, лишний круг обновления «ребёнок → родитель» и гонку запросов.
На review мы не обсуждали массивы зависимостей по очереди. Сначала классифицировали причины:
total вычисляется из items — значит, это рендер;
создание заявки запускает пользователь — значит, это обработчик или Action;
родителю лучше передавать изменение в том же событии либо сделать форму управляемой;
варианты поиска — серверное состояние, для которого нужен хотя бы хук с отменой запроса, а в продукте с кэшем — query-библиотека.
После такого разбора вопрос «как починить четыре эффекта?» превращается в «почему они появились?». Именно этот навык полезен на уровне команды: не запрещать API, а вырабатывать общий способ выбирать абстракцию.
В команде я использую этот подход как часть инженерной культуры: повторяющееся замечание сначала превращается в короткое правило для review, затем — в пример и проверяемый сценарий. Так знания перестают жить только в голове лида.
Классический антипаттерн — хранить значение, которое полностью определяется другим состоянием.
// ❌ Лишнее состояние и дополнительный проход рендера const [firstName, setFirstName] = useState("Петр"); const [lastName, setLastName] = useState("Всемогущий"); const [fullName, setFullName] = useState(""); useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]);
После изменения имени React сначала отрендерит компонент со старым fullName, затем запустит эффект, обновит состояние и выполнит ещё один рендер. На небольшом компоненте цена мала, но с ростом формы такие каскадные обновления усложняют поток данных и могут показывать промежуточное значение.
// ✅ Один источник правды const [firstName, setFirstName] = useState("Петр"); const [lastName, setLastName] = useState("Всемогущий"); const fullName = `${firstName} ${lastName}`;
Если значение полностью выводится из текущих props и state, вычисляйте его во время рендера. Это Demo 01 в playground.

Тяжёлое вычисление тоже не становится состоянием только из-за своей стоимости.
// ❌ Результат приходится синхронизировать эффектом const [visibleTodos, setVisibleTodos] = useState<Todo[]>([]); useEffect(() => { setVisibleTodos(getFilteredTodos(todos, filter)); }, [todos, filter]);
Сначала удаляем лишнее состояние. Если профилирование показывает, что повторный расчёт действительно дорог, добавляем мемоизацию:
// ✅ Мемоизируем измеримо дорогой расчёт const visibleTodos = useMemo( () => getFilteredTodos(todos, filter), [todos, filter], );
useMemo — оптимизация производительности, а не условие корректности. Для дешёвых вычислений он может добавить больше сложности, чем пользы. Если в проекте настроен React Compiler, часть ручной мемоизации он способен выполнить автоматически, но это не отменяет измерения и не исправляет лишнее состояние.

В Demo 02 можно сравнить оба варианта в React DevTools Profiler.
Если при смене userId нужно сбросить всё локальное состояние профиля, эффект работает, но делает это после первого рендера новой сущности.
// ❌ Сначала рендер со старым комментарием, затем сброс function Profile({ userId }: { userId: string }) { const [comment, setComment] = useState(""); useEffect(() => { setComment(""); }, [userId]); }
Лучше сообщить React, что перед ним другая сущность:
// ✅ Новый key — новый экземпляр поддерева function ProfilePage({ userId }: { userId: string }) { return <Profile key={userId} userId={userId} />; }
При изменении key React пересоздаст поддерево и заново инициализирует его локальное состояние. Это подход для полного сброса; использовать key как универсальный способ «починить компонент» не стоит. Сценарий показан в Demo 03.
Часто в состоянии хранят выбранный объект, а при обновлении списка сбрасывают его эффектом:
// ❌ Объект может перестать соответствовать новому списку const [selection, setSelection] = useState<Item | null>(null); useEffect(() => { setSelection(null); }, [items]);
Обычно достаточно хранить стабильный идентификатор:
// ✅ Минимальное состояние, объект выводится во время рендера const [selectedId, setSelectedId] = useState<string | null>(null); const selection = items.find((item) => item.id === selectedId) ?? null;
Если элемент исчезнет, selection станет null без дополнительного обновления состояния. Поведение отличается от безусловного сброса: выбор сохранится, если элемент всё ещё есть в списке. Именно такое поведение обычно и требуется, но его следует выбирать осознанно. См. Demo 04.
Итог части: второй источник правды почти неизбежно требует синхронизации. Поэтому до добавления эффекта полезно проверить, не появилось ли лишнее состояние несколькими строками выше.
Иногда эффект используют, чтобы не дублировать действие в двух обработчиках:
// ❌ Уведомление привязано к состоянию, а не к причине изменения useEffect(() => { if (product.isInCart) { showNotification(`Добавлено: ${product.name}`); } }, [product]);
Такой код сработает не только после клика, но и, например, после восстановления корзины из хранилища. Если уведомление нужно именно в ответ на действие пользователя, общую логику лучше вынести в функцию:
// ✅ Причина выполнения видна из обработчика function buyProduct() { addToCart(product); showNotification(`Добавлено: ${product.name}`); } function handleBuyClick() { buyProduct(); } function handleCheckoutClick() { buyProduct(); navigateTo("/checkout"); }
Это Demo 05. Обычная функция часто оказывается подходящей абстракцией — не всякое переиспользование требует хука.
Сигнальное состояние добавляет промежуточный рендер между событием и запросом:
// ❌ Состояние существует только для запуска эффекта const [payload, setPayload] = useState<Registration | null>(null); useEffect(() => { if (payload !== null) { registerUser(payload); } }, [payload]); function handleSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setPayload({ firstName, lastName }); }
В обычном клиентском коде запрос можно выполнить прямо в handleSubmit. В React 19 для форм также доступны Actions и useActionState:
// ✅ Action формы хранит причину и результат рядом const [result, formAction, isPending] = useActionState( async (_previous: FormResult, formData: FormData): Promise<FormResult> => { try { const response = await registerUser({ firstName: String(formData.get("firstName") ?? ""), lastName: String(formData.get("lastName") ?? ""), }); return { ok: true, message: `Создан пользователь #${response.id}` }; } catch (error) { return { ok: false, message: (error as Error).message }; } }, { ok: false, message: "" }, ); return ( <form action={formAction}> <input name="firstName" /> <input name="lastName" /> <button disabled={isPending}> {isPending ? "Отправка…" : "Отправить"} </button> <output>{result.message}</output> </form> );
Actions не заменяют все сетевые запросы. Здесь они подходят потому, что мутация является действием формы и нужен связанный с ней pending и результат. Полный пример — Demo 06.
Несколько эффектов, обновляющих состояние друг друга, превращают одно событие в каскад рендеров:
// ❌ Результат одного эффекта запускает следующий useEffect(() => { if (card?.gold) setGoldCount((count) => count + 1); }, [card]); useEffect(() => { if (goldCount > 3) { setRound((value) => value + 1); setGoldCount(0); } }, [goldCount]); useEffect(() => { if (round > 5) setIsGameOver(true); }, [round]);
Если вся цепочка началась с одного действия, новое состояние можно получить одной чистой функцией или редьюсером:
// ✅ Один переход состояния, который легко протестировать function placeCard(state: GameState, isGold: boolean): GameState { if (state.isGameOver) return state; let { round, goldCount } = state; if (isGold && goldCount < 3) { goldCount += 1; } else if (isGold) { goldCount = 0; round += 1; } return { round, goldCount, isGameOver: round > 5, }; }
Demo 07 показывает обе реализации и содержит тесты переходов состояния.
Эффект в дочернем компоненте заставляет обновление пройти два круга: сначала ребёнок, затем родитель.
// ❌ Колбэк вызывается после отдельного рендера ребёнка function Toggle({ onChange }: ToggleProps) { const [isOn, setIsOn] = useState(false); useEffect(() => { onChange(isOn); }, [isOn, onChange]); }
Если изменение вызвал пользователь, уведомляем родителя в том же событии:
// ✅ Оба обновления React сможет обработать вместе function handleClick() { const next = !isOn; setIsOn(next); onChange(next); }
Если родителю всегда нужно знать значение, стоит рассмотреть полностью управляемый компонент: хранить isOn у родителя и передавать его вместе с onChange. Оба решения представлены в Demo 08.
Итог части: обработчик знает причину выполнения кода, эффект — только факт изменения зависимостей. Не создавайте состояние исключительно как сигнал для следующего действия.
Пустой массив зависимостей означает «на каждый монтаж компонента», а не «один раз за жизнь приложения». В Strict Mode React дополнительно выполняет в development цикл setup → cleanup → setup, чтобы обнаружить эффекты без симметричной очистки.
// ❌ Это жизненный цикл компонента, а не приложения useEffect(() => { checkAuthToken(); loadDataFromLocalStorage(); }, []);
Если логика действительно должна выполниться один раз при запуске клиентского приложения, явнее вызвать её из точки входа. Guard защищает от повторной инициализации в пределах одного экземпляра модуля:
let didInit = false; export function initAppOnce() { if (didInit) return; didInit = true; checkAuthToken(); loadDataFromLocalStorage(); } // client entry point initAppOnce(); createRoot(rootElement).render(<App />);
В приложениях с SSR, HMR или несколькими runtime-экземплярами семантику «один раз» нужно определять отдельно: модульный флаг не является глобальной распределённой блокировкой. Если же ресурс должен существовать именно пока смонтирован компонент, эффект остаётся правильным местом. Эти различия можно проверить в Demo 09.
Ручная подписка через эффект возможна, но для внешнего изменяемого источника React предоставляет специализированный хук:
// ✅ Согласованное чтение внешнего состояния function subscribe(callback: () => void) { window.addEventListener("online", callback); window.addEventListener("offline", callback); return () => { window.removeEventListener("online", callback); window.removeEventListener("offline", callback); }; } function useOnlineStatus() { return useSyncExternalStore( subscribe, () => navigator.onLine, () => true, ); }
useSyncExternalStore разделяет подписку, чтение актуального snapshot и серверный snapshot. Это снижает риск рассинхронизации при SSR и конкурентном рендере. Функцию subscribe важно объявить вне компонента или стабилизировать, иначе React будет переподписываться. Сравнение с ручным эффектом находится в Demo 10.
Подключение к чату — настоящий эффект: соединение нужно создать при появлении компонента и закрыть при смене комнаты или размонтировании.
// ❌ Смена темы пересоздаёт соединение useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { showNotification(theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]);
theme нужна обработчику уведомления, но не жизненному циклу соединения. В React 19.2 эту нереактивную часть можно отделить с помощью useEffectEvent:
// ✅ Effect Event читает актуальную тему, не переподключая чат const onConnected = useEffectEvent(() => { showNotification(theme); }); useEffect(() => { const connection = createConnection(roomId); connection.on("connected", onConnected); connection.connect(); return () => connection.disconnect(); }, [roomId]);

Effect Event следует вызывать только из эффекта или из кода, запущенного эффектом. Это не способ скрывать реальные зависимости от линтера: реактивная логика должна оставаться в эффекте и перечисляться в dependencies. Demo 11 позволяет переключать тему и наблюдать количество переподключений.
WebSocket, SSE и другие соединения;
таймеры, интервалы и внешние подписки;
синхронизация с видеоплеером, картой, редактором и другим императивным виджетом;
браузерные API, для которых нет более подходящего React-хука;
аналитика факта показа экрана, если её семантика действительно связана с показом.
Для ресурсов эффект обычно имеет симметричную пару setup/cleanup. При прямой работе с DOM нужно учитывать момент выполнения: измерение layout или визуальное позиционирование до отрисовки может потребовать useLayoutEffect, а некоторые задачи фокуса решаются autoFocus или callback ref без эффекта.
Итог части: наличие внешней системы ещё не означает, что нужен именно ручной useEffect. Сначала проверьте специализированные интерфейсы — useSyncExternalStore, API фреймворка или хук библиотеки. Если жизненным циклом подключения должен управлять компонент, эффект подходит.
За последние годы изменился не только React API, но и сам уровень абстракции, на котором мы работаем с серверным состоянием.

React и экосистема постепенно поднимают уровень абстракции: от ручного fetch в useEffect к query-библиотекам, Server Components, use() и Actions.
Загрузка данных по изменению query действительно синхронизирует интерфейс с сервером, поэтому эффект здесь не является концептуальной ошибкой. Опасен слишком упрощённый вариант:
// ❌ Ответ старого запроса может перезаписать новый useEffect(() => { fetchResults(query).then(setResults); }, [query]);
Если пользователь быстро введёт несколько значений, ответы могут прийти в другом порядке. Кроме гонок, реальному приложению часто нужны кэш, повторные попытки, дедупликация, SSR и фоновое обновление.
Для небольшого client-only сценария допустим собственный хук с отменой:
useEffect(() => { const controller = new AbortController(); let ignore = false; fetchResults(query, controller.signal) .then((data) => { if (!ignore) setResults(data); }) .catch((error) => { if (error.name !== "AbortError" && !ignore) { setError(error); } }); return () => { ignore = true; controller.abort(); }; }, [query]);
AbortController прекращает ненужную работу, а ignore защищает состояние, даже если конкретный источник данных не поддерживает отмену полностью. В production-хуке также нужно явно управлять loading, сбрасывать предыдущую ошибку и проверять поведение при повторном запросе.
Для клиентского server state часто подходит TanStack Query:
const { data, isFetching, error } = useQuery({ queryKey: ["search", query], queryFn: ({ signal }) => searchProducts(query, signal), });
Библиотека добавляет кэш, повторные попытки, дедупликацию и фоновое обновление. Это не обязательный выбор для каждого проекта: если фреймворк уже управляет загрузкой и кэшированием, дополнительный клиентский cache layer может быть не нужен.
React 19 позволяет читать Promise во время рендера:
function Results({ query }: { query: string }) { const data = use(getSearchPromise(query)); return <ResultList items={data} />; }
Promise должен быть стабильным и обычно предоставляться фреймворком или кэширующим слоем. Создавать новый Promise непосредственно при каждом клиентском рендере нельзя: это приведёт к повторным приостановкам и предупреждениям. Ошибки обрабатывает Error Boundary, ожидание — ближайший Suspense.
Если проект уже использует Redux Toolkit, логично рассмотреть RTK Query:
const { data, isFetching, error } = useSearchQuery(query);
Он решает сходные задачи и интегрируется с существующим Redux store.

Все четыре варианта собраны в Demo 12. React Compiler намеренно не включён в этот список: он оптимизирует вычисления компонентов, но не управляет жизненным циклом серверных данных.
Все примеры выше можно свести к одному практическому фильтру, который удобно использовать на code review.

Задача | Сначала проверить | Демо |
|---|---|---|
Вычислить значение из | Вычисление во время рендера | 01 |
Не повторять дорогой расчёт | Измерение + | 02 |
Полностью сбросить состояние сущности |
| 03 |
Согласовать выбор с новым списком | Хранить | 04 |
Переиспользовать логику событий | Обычная функция | 05 |
Отправить форму или мутацию | Обработчик / Action | 06 |
Связать цепочку обновлений | Один обработчик / редьюсер | 07 |
Уведомить родителя | Колбэк в событии / controlled component | 08 |
Выполнить инициализацию приложения | Точка входа и явная семантика | 09 |
Читать внешний изменяемый store |
| 10 |
Отделить нереактивный обработчик эффекта |
| 11 |
Загрузить серверные данные | Хук, query-библиотека или API фреймворка | 12 |
Зрелость React-разработчика определяется не количеством известных хуков, а качеством границ ответственности. Он умеет отличить вычисление от состояния, событие от жизненного цикла, локальные данные от серверных и ручной механизм от готовой абстракции.
Для тимлида следующий шаг — сделать это знание воспроизводимым:
формулировать на review не только исправление, но и причину;
собирать повторяющиеся замечания в короткие правила;
добавлять минимальные демо и тесты на гонки или переходы состояния;
обсуждать исключения, чтобы правило не превратилось в запрет.
Мы не вводим правило «никаких эффектов». Мы договариваемся сначала называть внешнюю систему и жизненный цикл синхронизации. Если назвать их нельзя, стоит поискать более простую модель.
Меня зовут Виктор Горбачёв. Я руководитель группы разработки с фокусом на frontend: больше семи лет занимаюсь коммерческой разработкой и больше двух — руковожу кросс-функциональной командой. Запускал сложный пользовательский кабинет с нуля до production, развивал микрофронтенд-архитектуру на Module Federation, выстраивал code review и инженерные практики. До тимлидства работал senior React-разработчиком и преподавал frontend/React.
Мне интересны не только отдельные технологии, но и способы превращать техническую экспертизу в командный результат: понятные архитектурные решения, воспроизводимые процессы и развитие разработчиков.
Если у вашей команды есть свой пограничный случай с useEffect, приносите его в комментарии. Самые интересные обсуждения обычно начинаются именно там, где простое правило перестаёт быть достаточным.