На 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 point
initAppOnce();
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, приносите его в комментарии. Самые интересные обсуждения обычно начинаются именно там, где простое правило перестаёт быть достаточным.
Автор: Viktoriko
