javascript

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

  • воскресенье, 5 июля 2026 г. в 00:00:08
https://habr.com/ru/articles/1055486/
Не каждый код «после рендера» должен становиться useEffect. Сначала ищем причину выполнения.
Не каждый код «после рендера» должен становиться useEffect. Сначала ищем причину выполнения.

На 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 есть три основных места выполнения.

  1. Рендер — для чистых вычислений из текущих props и state.

  2. Обработчик события или Action — для логики, причиной которой стало действие пользователя.

  3. Эффект — для синхронизации с системой, жизненным циклом которой React не управляет.

Рендер должен оставаться чистым: одинаковые входные данные дают одинаковый JSX, без запросов, подписок и изменения внешнего состояния. Обработчик знает, какое действие совершил пользователь. Эффект этого не знает — он видит только, что компонент был добавлен на страницу или изменились его реактивные зависимости.

Перед новым useEffect я задаю два вопроса:

  1. Можно ли вычислить это значение из уже имеющихся данных? Тогда вычисляю его во время рендера.

  2. Почему должен выполниться этот код? Если из-за клика, ввода или отправки формы — помещаю его в обработчик или Action. Если из-за необходимости поддерживать соединение с внешней системой — использую эффект.

Три способа выполнить код в React: рендер, событие и эффект.
Три способа выполнить код в React: рендер, событие и эффект.

В 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, часть ручной мемоизации он способен выполнить автоматически, но это не отменяет измерения и не исправляет лишнее состояние.

useMemo полезен для измеримо дорогих вычислений, но не должен заменять удаление лишнего состояния.
useMemo полезен для измеримо дорогих вычислений, но не должен заменять удаление лишнего состояния.

В 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]);
useEffectEvent отделяет жизненный цикл подключения от нереактивной логики события.
useEffectEvent отделяет жизненный цикл подключения от нереактивной логики события.

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
Эволюция работы с серверным состоянием в React

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.

Для загрузки данных выбор зависит не от useEffect, а от уровня абстракции: ручной хук, query-библиотека, Suspense или RTK Query.
Для загрузки данных выбор зависит не от useEffect, а от уровня абстракции: ручной хук, query-библиотека, Suspense или RTK Query.

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


Чек-лист перед новым useEffect

Все примеры выше можно свести к одному практическому фильтру, который удобно использовать на code review.

Дерево решений: когда нужен useEffect, а когда лучше выбрать рендер, событие, Action, store-хук или query-библиотеку.
Дерево решений: когда нужен useEffect, а когда лучше выбрать рендер, событие, Action, store-хук или query-библиотеку.

Задача

Сначала проверить

Демо

Вычислить значение из props/state

Вычисление во время рендера

01

Не повторять дорогой расчёт

Измерение + useMemo

02

Полностью сбросить состояние сущности

key

03

Согласовать выбор с новым списком

Хранить id, объект вычислять

04

Переиспользовать логику событий

Обычная функция

05

Отправить форму или мутацию

Обработчик / Action

06

Связать цепочку обновлений

Один обработчик / редьюсер

07

Уведомить родителя

Колбэк в событии / controlled component

08

Выполнить инициализацию приложения

Точка входа и явная семантика

09

Читать внешний изменяемый store

useSyncExternalStore

10

Отделить нереактивный обработчик эффекта

useEffectEvent

11

Загрузить серверные данные

Хук, query-библиотека или API фреймворка

12

Что этот подход говорит об инженерной зрелости

Зрелость React-разработчика определяется не количеством известных хуков, а качеством границ ответственности. Он умеет отличить вычисление от состояния, событие от жизненного цикла, локальные данные от серверных и ручной механизм от готовой абстракции.

Для тимлида следующий шаг — сделать это знание воспроизводимым:

  • формулировать на review не только исправление, но и причину;

  • собирать повторяющиеся замечания в короткие правила;

  • добавлять минимальные демо и тесты на гонки или переходы состояния;

  • обсуждать исключения, чтобы правило не превратилось в запрет.

Мы не вводим правило «никаких эффектов». Мы договариваемся сначала называть внешнюю систему и жизненный цикл синхронизации. Если назвать их нельзя, стоит поискать более простую модель.

Репозиторий и источники

Об авторе

Меня зовут Виктор Горбачёв. Я руководитель группы разработки с фокусом на frontend: больше семи лет занимаюсь коммерческой разработкой и больше двух — руковожу кросс-функциональной командой. Запускал сложный пользовательский кабинет с нуля до production, развивал микрофронтенд-архитектуру на Module Federation, выстраивал code review и инженерные практики. До тимлидства работал senior React-разработчиком и преподавал frontend/React.

Мне интересны не только отдельные технологии, но и способы превращать техническую экспертизу в командный результат: понятные архитектурные решения, воспроизводимые процессы и развитие разработчиков.

Если у вашей команды есть свой пограничный случай с useEffect, приносите его в комментарии. Самые интересные обсуждения обычно начинаются именно там, где простое правило перестаёт быть достаточным.