На 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 важно сначала определить причину выполнения кода: вычисление, действие пользователя или синхронизация с внешней системой.
Как это выглядит на code review
Ниже не дословный фрагмент одного рабочего 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, затем — в пример и проверяемый сценарий. Так знания перестают жить только в голове лида.
Часть I. Не храните то, что можно вычислить
01. Производное состояние
Классический антипаттерн — хранить значение, которое полностью определяется другим состоянием.
// ❌ Лишнее состояние и дополнительный проход рендера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.
02. Кэш дорогого вычисления
Тяжёлое вычисление тоже не становится состоянием только из-за своей стоимости.
// ❌ Результат приходится синхронизировать эффектом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.
03. Полный сброс состояния при смене сущности
Если при смене 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.
04. Корректировка части состояния при смене данных
Часто в состоянии хранят выбранный объект, а при обновлении списка сбрасывают его эффектом:
// ❌ Объект может перестать соответствовать новому списку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.
Итог части: второй источник правды почти неизбежно требует синхронизации. Поэтому до добавления эффекта полезно проверить, не появилось ли лишнее состояние несколькими строками выше.
Часть II. События — не эффекты
05. Общая логика нескольких обработчиков
Иногда эффект используют, чтобы не дублировать действие в двух обработчиках:
// ❌ Уведомление привязано к состоянию, а не к причине изменения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. Обычная функция часто оказывается подходящей абстракцией — не всякое переиспользование требует хука.
06. POST-запрос при отправке формы
Сигнальное состояние добавляет промежуточный рендер между событием и запросом:
// ❌ Состояние существует только для запуска эффекта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.
07. Цепочки эффектов
Несколько эффектов, обновляющих состояние друг друга, превращают одно событие в каскад рендеров:
// ❌ Результат одного эффекта запускает следующий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 показывает обе реализации и содержит тесты переходов состояния.
08. Уведомление родителя
Эффект в дочернем компоненте заставляет обновление пройти два круга: сначала ребёнок, затем родитель.
// ❌ Колбэк вызывается после отдельного рендера ребёнка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.
Итог части: обработчик знает причину выполнения кода, эффект — только факт изменения зависимостей. Не создавайте состояние исключительно как сигнал для следующего действия.
Часть III. Внешний мир и жизненный цикл
09. Инициализация приложения
Пустой массив зависимостей означает «на каждый монтаж компонента», а не «один раз за жизнь приложения». В 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 pointinitAppOnce();createRoot(rootElement).render(<App />);
В приложениях с SSR, HMR или несколькими runtime-экземплярами семантику «один раз» нужно определять отдельно: модульный флаг не является глобальной распределённой блокировкой. Если же ресурс должен существовать именно пока смонтирован компонент, эффект остаётся правильным местом. Эти различия можно проверить в Demo 09.
10. Подписка на внешний store
Ручная подписка через эффект возможна, но для внешнего изменяемого источника 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.
11. Подключение к внешней системе и useEffectEvent
Подключение к чату — настоящий эффект: соединение нужно создать при появлении компонента и закрыть при смене комнаты или размонтировании.
// ❌ Смена темы пересоздаёт соединение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 позволяет переключать тему и наблюдать количество переподключений.
Где обычный useEffect остаётся уместным
-
WebSocket, SSE и другие соединения;
-
таймеры, интервалы и внешние подписки;
-
синхронизация с видеоплеером, картой, редактором и другим императивным виджетом;
-
браузерные API, для которых нет более подходящего React-хука;
-
аналитика факта показа экрана, если её семантика действительно связана с показом.
Для ресурсов эффект обычно имеет симметричную пару setup/cleanup. При прямой работе с DOM нужно учитывать момент выполнения: измерение layout или визуальное позиционирование до отрисовки может потребовать useLayoutEffect, а некоторые задачи фокуса решаются autoFocus или callback ref без эффекта.
Итог части: наличие внешней системы ещё не означает, что нужен именно ручной useEffect. Сначала проверьте специализированные интерфейсы — useSyncExternalStore, API фреймворка или хук библиотеки. Если жизненным циклом подключения должен управлять компонент, эффект подходит.
Часть IV. Загрузка серверных данных
За последние годы изменился не только React API, но и сам уровень абстракции, на котором мы работаем с серверным состоянием.
React и экосистема постепенно поднимают уровень абстракции: от ручного fetch в useEffect к query-библиотекам, Server Components, use() и Actions.
12. Четыре подхода к fetching
Загрузка данных по изменению 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, сбрасывать предыдущую ошибку и проверять поведение при повторном запросе.
TanStack Query
Для клиентского server state часто подходит TanStack Query:
const { data, isFetching, error } = useQuery({ queryKey: ["search", query], queryFn: ({ signal }) => searchProducts(query, signal),});
Библиотека добавляет кэш, повторные попытки, дедупликацию и фоновое обновление. Это не обязательный выбор для каждого проекта: если фреймворк уже управляет загрузкой и кэшированием, дополнительный клиентский cache layer может быть не нужен.
use() + Suspense
React 19 позволяет читать Promise во время рендера:
function Results({ query }: { query: string }) { const data = use(getSearchPromise(query)); return <ResultList items={data} />;}
Promise должен быть стабильным и обычно предоставляться фреймворком или кэширующим слоем. Создавать новый Promise непосредственно при каждом клиентском рендере нельзя: это приведёт к повторным приостановкам и предупреждениям. Ошибки обрабатывает Error Boundary, ожидание — ближайший Suspense.
RTK Query
Если проект уже использует Redux Toolkit, логично рассмотреть RTK Query:
const { data, isFetching, error } = useSearchQuery(query);
Он решает сходные задачи и интегрируется с существующим Redux store.
Все четыре варианта собраны в Demo 12. React Compiler намеренно не включён в этот список: он оптимизирует вычисления компонентов, но не управляет жизненным циклом серверных данных.
Чек-лист перед новым useEffect
Все примеры выше можно свести к одному практическому фильтру, который удобно использовать на 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, приносите его в комментарии. Самые интересные обсуждения обычно начинаются именно там, где простое правило перестаёт быть достаточным.
ссылка на оригинал статьи https://habr.com/ru/articles/1055486/