open atlas
↑ К треку
Паттерны React RXP · 14 · 02

Эффект-как-производное-состояние

Самый частый анти-паттерн React: useEffect, вся работа которого — setState из пропсов или состояния. Он стоит лишнего рендера и на один тик показывает устаревшее значение. Удали его — вычисляй в рендере или сбрасывай через key, — но не если он синхронизирует внешнюю систему.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Есть один анти-паттерн React, который ты найдёшь почти в каждой кодовой базе, куда придёшь, — нередко десятки раз: useEffect, всё тело которого — это setState, а массив зависимостей — какое-то другое состояние или проп. Он выглядит как ответственное поведение — «когда инпут меняется, держим производное значение в синхроне», — и это самый надёжный источник ошибок «на один рендер позже» в React.

Эффект ни с чем за пределами React не синхронизируется. Он копирует состояние React в ещё одно состояние React, на один рендер слишком поздно. Эта задержка — не граничный случай, который можно залатать; это определяющее поведение паттерна. Senior-исправление почти никогда не «эффект получше». Это удаление эффекта.

Цель

После этого урока ты можешь распознать эффект-как-производное-состояние с одного взгляда — useEffect, тело которого только setState, а зависимости — другое состояние/пропсы — и точно объяснить, почему он неверен: лишний рендер и окно в один тик, где отзеркаленное значение устарело. Ты можешь убрать его двумя способами: вычислять значение во время рендера, когда это чистая функция входов, или сбрасывать состояние через key, когда смена пропа должна его обнулить. И, что критично, ты можешь отличить этот анти-паттерн от настоящего эффекта внешней синхронизации, чтобы не перестараться и не удалить эффект, который реально делает свою работу.

1

Форма, которую надо распознавать: эффект, вся работа которого — setState из другого состояния или пропсов. Никакой подписки, никакого fetch, никакого DOM API, никакого таймера — просто один кусок состояния React, отзеркаливаемый в другой. В тот момент, как ты это видишь, эффект — кандидат на удаление.

function ProfileForm({ user }: { user: User }) {
  const [firstName, setFirstName] = useState(user.firstName);
  const [fullName, setFullName] = useState("");

  // анти-паттерн: вся работа этого эффекта — setState-из-состояния
  useEffect(() => {
    setFullName(firstName + " " + user.lastName);
  }, [firstName, user.lastName]);

  return <input value={firstName} onChange={(e) => setFirstName(e.target.value)} />;
}

fullName — не независимое состояние, оно полностью определено через firstName и user.lastName. Хранить его отдельно и ре-синхронизировать в эффекте — это и есть баг. Тест: если ты можешь записать значение как чистое выражение из пропсов и текущего состояния, оно производное, а не состояние.

2

Почему он неверен, механически: лишний рендер и устаревший тик. React рендерит с новым firstName, потом отрисовывает, потом запускает эффект, который вызывает setFullName, который планирует второй рендер. Между этими двумя рендерами есть кадр, где firstName — «Taylor», а fullName всё ещё читается как «Jordan Smith». На долю секунды UI внутренне противоречив — реальная, наблюдаемая вспышка устаревших данных плюс удвоенное число рендеров.

// рендер 1: firstName="Taylor", fullName="Jordan Smith"  ← устарело, отрисовано
// эффект запускается → setFullName("Taylor Smith")
// рендер 2: firstName="Taylor", fullName="Taylor Smith"  ← наконец верно

Становится хуже в тот момент, когда второе производное значение зависит от первого, потому что каждый эффект добавляет ещё один тик задержки, а массивы зависимостей начинают расходиться друг с другом. Это не придирка к производительности — это баг корректности, который вдобавок ещё и медленный.

3

Исправление №1 — вычисляй во время рендера. Никакого состояния, никакого эффекта. Значение, которое является чистой функцией пропсов и состояния, должно вычисляться инлайн. Нет устаревшего тика, потому что нет второго рендера: значение всегда выведено из тех входов, что породили текущий рендер.

function ProfileForm({ user }: { user: User }) {
  const [firstName, setFirstName] = useState(user.firstName);
  const fullName = firstName + " " + user.lastName; // производное, всегда свежее

  return <input value={firstName} onChange={(e) => setFirstName(e.target.value)} />;
}

Два useState стали одним, эффекта нет, и окно несогласованности ушло вместе с ним. Если бы вычисление было реально дорогим (сортировка десяти тысяч строк на каждое нажатие клавиши), ты бы обернул его в useMemo — но useMemo — это всё ещё вывод во время рендера, а не эффект. Тянись к нему только когда профилирование показывает, что стоимость реальна; для конкатенации строк и обычных фильтров чистое вычисление во время рендера корректно и дешевле, чем бухгалтерия мемоизации.

4

Исправление №2 — когда смена пропа должна сбросить внутреннее состояние, используй key, а не эффект. Близкий родственник анти-паттерна — «когда меняется userId, очисти черновик и выбор». Люди тянутся к эффекту, который сбрасывает несколько useState. У него та же проблема устаревшего тика, и его легко сделать частично неправильным. Вместо этого дай поддереву key, привязанный к идентичности, которая должна его сбросить, — React размонтирует и заново монтирует его, сбрасывая всё его состояние за один заход, в том же коммите.

// анти-паттерн: эффект зеркалит смену пропа в сброс состояния (устаревший тик + баги частичного сброса)
useEffect(() => { setDraft(""); setSelection(null); }, [userId]);

// исправление: смена идентичности → ремаунт → всё внутреннее состояние сбрасывается атомарно
<ProfileEditor key={userId} userId={userId} />

Подход с key масштабируется: он сбрасывает всё состояние в поддереве, включая то, что ты забыл бы сбросить руками, без лишнего рендера и без вспышки. Используй его всякий раз, когда «этот проп изменился, значит начинаем с чистого листа» — реальное намерение.

Разбор примера

Список с фильтром — анти-паттерн и его удаление, бок о бок. Список товаров фильтруется по поисковому запросу. Первая версия зеркалит отфильтрованный результат в состояние через эффект.

function ProductList({ products }: { products: Product[] }) {
  const [query, setQuery] = useState("");
  const [visible, setVisible] = useState(products);

  // анти-паттерн: visible производное, но хранится + синхронизируется в эффекте
  useEffect(() => {
    setVisible(products.filter((p) => p.name.includes(query)));
  }, [query, products]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
    </>
  );
}

Набери символ — и список на один кадр показывает предыдущий результат фильтра, пока эффект не догонит. Если products приходят из fetch и меняются, есть рендер, где visible — это старые товары против нового запроса. Исправление удаляет состояние и эффект целиком:

function ProductList({ products }: { products: Product[] }) {
  const [query, setQuery] = useState("");
  const visible = products.filter((p) => p.name.includes(query)); // производное в рендере

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>
    </>
  );
}

Теперь visible никогда не сможет разойтись с query или products, потому что пересчитывается из них на каждом рендере — никакого второго рендера, никакого устаревшего кадра. Один useState и один эффект были чистыми накладными расходами. Если бы фильтр был реально дорогим, ты бы потянулся к useMemo(() => products.filter(...), [products, query]), но это всё ещё вывод в рендере, никогда не эффект.

Почему это работает

Почему вывод в рендере кажется «расточительным» тем, кто тянется к эффекту? Потому что они боятся пересчёта на каждом рендере. Но пересчёт — это и есть рендер: UI = f(state) значит, что компонент всё равно прогоняется сверху вниз на каждом рендере. Простой .filter() или конкатенация строк дешевле, чем стоимость, которую добавляет эффект: лишний рендер компонента, проход согласования и диф. Ты не экономишь работу, зеркаля в состояние; ты добавляешь рендер и окно неверных данных, чтобы избежать работы, которая и не была дорогой изначально.

Частая ошибка

Опасная перестраховка: удаление эффекта, который реально синхронизируется с чем-то вне React. «Эффект-как-производное-состояние» применим только когда и вход, и выход эффекта — это состояние/пропсы React. Эффект, который подписывается на WebSocket, читает localStorage, синхронизирует с document.title, ставит ResizeObserver или подключает не-React-виджет, делает настоящую внешнюю синхронизацию — его нельзя заменить вычислением во время рендера, потому что рендер должен оставаться чистым и без побочных эффектов. Признак — источник: если значение приходит извне React (сеть, DOM, браузерный API, другая вкладка), оставь эффект (или предпочти useSyncExternalStore для внешних сторов). Если оба конца — состояние React, удали его. Спутать второй вид с первым — вот как «чистящий» PR удаляет единственное, что держит заголовок вкладки в синхроне.

Проверь себя
Викторина

В компоненте есть `const [total, setTotal] = useState(0)` плюс `useEffect(() => setTotal(items.reduce((s, i) => s + i.price, 0)), [items])`. В чём точная проблема и в чём исправление?

Итог

Эффект-как-производное-состояние — самый частый анти-паттерн React: useEffect, вся работа которого — setState из другого состояния или пропсов. Он неверен по построению — React коммитит рендер со старым отзеркаленным значением, затем запускается эффект и планирует второй рендер, чтобы это починить, оставляя окно в один тик, где UI внутренне противоречив, плюс стоимость лишнего рендера. Исправление почти никогда не «эффект получше»; это отсутствие эффекта. Когда значение — чистая функция входов, вычисляй его во время рендера (и используй useMemo только когда профилирование доказывает, что стоимость реальна). Когда смена пропа должна сбросить внутреннее состояние, используй key, чтобы React заново смонтировал поддерево и сбросил его всё атомарно. Единственное, чего делать нельзя, — перестраховываться: эффект, источник которого вне React — сокет, DOM, localStorage, другая вкладка — это настоящая синхронизация, и её нельзя вывести в рендере. Различитель — всегда источник. Состояние React на входе, состояние React на выходе: удали его. Внешний мир на входе: оставь его.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.