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

Возможно, эффект тебе не нужен

Большинство эффектов в реальном коде не нужны и багованы. Каталог эффектов на удаление — производное состояние, преобразования для отображения, сбросы по пропсам, кэширование, уведомление родителя, цепочки апдейтов — и один эффект, который нельзя удалять по ошибке.

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

Открой почти любой React-проект — и найдёшь эффекты, существующие лишь затем, чтобы держать один кусок состояния в синхроне с другим. useEffect, который пересчитывает отфильтрованный список при смене запроса. Тот, что сбрасывает форму при смене выбранного пользователя. Тот, что вызывает onChange после того, как апдейт состояния зафиксировался. Каждый по отдельности выглядит разумно, и каждый — баг, готовый выстрелить: лишний рендер, протухшее замыкание, вспышка неверных данных, синхронизация, отстающая на один тик.

Senior-ход — не писать лучшие эффекты. А удалять их. Эффект — это синхронизация с системой вне React; если внешней системы нет, эффект почти наверняка заново выводит данные, которые у тебя уже есть, а выводить их заново в эффекте — медленный и гоночный способ сделать то, что рендер делает бесплатно. Этот урок — каталог эффектов на удаление, и одного, который удалять нельзя.

Цель

После этого урока ты можешь распознать шесть самых частых паттернов ненужных эффектов — производное состояние, преобразование данных для отображения, сброс состояния при смене пропса, кэширование дорогих результатов, уведомление родителя и цепочки апдейтов состояния — и убрать каждый правильным инструментом (вычисление во время рендера, key, useMemo, обработчик события). А ещё ты можешь отличить их от настоящего эффекта внешней синхронизации, чтобы удалить багованные, не удалив тот, что реально делает свою работу.

1

Если это можно вычислить из пропсов и состояния во время рендера — это не состояние, и в эффекте ему не место. Классический анти-паттерн хранит производное значение в useState и держит его актуальным эффектом. Это два рендера на каждое изменение (поставить источник, эффект сработал, поставить производное, рендер снова), окно, где эти двое расходятся, и целый массив зависимостей, который легко испортить. Исправление — вычислять инлайн прямо при рендере.

// ❌ производное состояние, синхронизированное эффектом: лишний рендер, может протухнуть
function FullName({ first, last }: { first: string; last: string }) {
  const [full, setFull] = useState("");
  useEffect(() => { setFull(`${first} ${last}`); }, [first, last]);
  return <h2>{full}</h2>;
}

// ✅ просто вычисли это — всегда согласованно, один рендер
function FullName({ first, last }: { first: string; last: string }) {
  const full = `${first} ${last}`;
  return <h2>{full}</h2>;
}

Рендер и есть синхронизация. Каждый раз, когда ловишь себя на написании setSomething внутри эффекта, чьи единственные входы — другие пропсы/состояние, тебе почти наверняка нужен обычный const.

2

Преобразование данных для отображения — тот же анти-паттерн, но со списком вместо строки, и с тем же исправлением. Фильтрация, сортировка, маппинг загруженного массива во view-модели: ничто из этого не трогает внешнюю систему, значит, ничто не место в эффекте. Вычисляй в рендере. Если — и только если — профилирование показывает, что преобразование действительно дорогое и выполняется на каждый несвязанный рендер, оберни его в useMemo. useMemo — это кэш времени рендера, а не эффект; он никогда не вызывает двойной-рендер-затем-вспышку, как версия с эффектом.

// ❌ эффект зеркалит производный список в состояние
const [visible, setVisible] = useState<Todo[]>([]);
useEffect(() => {
  setVisible(todos.filter((t) => t.status === filter));
}, [todos, filter]);

// ✅ выводи в рендере; мемоизируй, только если профилирование скажет, что фильтр горячий
const visible = useMemo(
  () => todos.filter((t) => t.status === filter),
  [todos, filter],
);

Тянись за useMemo, потому что так сказал замер, а не рефлекторно. Немемоизированный .filter() по паре сотен строк — нормально, а useMemo повсюду — просто шум, скрывающий тот единственный, который важен.

3

Чтобы сбросить состояние при смене пропса, дай компоненту key — не сбрасывай его эффектом. Частая форма: редактор профиля держит черновик состояния, а эффект очищает черновик всякий раз при смене userId. Этот эффект рендерит черновик старого пользователя один кадр, прежде чем сброс зафиксируется, и ему приходится перечислять каждый кусок состояния для очистки. У React уже есть примитив для «теперь это концептуально другой экземпляр»: смени key, и React размонтирует старое поддерево и смонтирует свежее с начальным состоянием. Ни эффекта, ни протухшего кадра, ни перечисления.

// ❌ эффект сбрасывает локальное состояние при смене идентичности — вспышка старого черновика
function Editor({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");
  useEffect(() => { setDraft(""); }, [userId]); // ещё: забыли про остальные 3 поля
  /* ... */
}

// ✅ key заставляет React перемонтировать свежий экземпляр — всё состояние сбрасывается атомарно
function EditorBoundary({ userId }: { userId: string }) {
  return <Editor key={userId} userId={userId} />;
}
function Editor({ userId }: { userId: string }) {
  const [draft, setDraft] = useState(""); // стартует чистым на каждый userId, без эффекта
  /* ... */
}

key живёт уровнем выше, на родителе, который решает идентичность. Это самый высокорычажный эффект на удаление: он убирает целый класс багов «почему форма показывает данные предыдущей записи».

4

Уведомляй родителя, запускай сайд-эффекты и связывай апдейты внутри обработчика события — не в эффекте, следящем за состоянием. Здесь живут два родственных запаха. Первый — уведомление родителя: эффект, вызывающий onChange(value) после изменения value, срабатывает на любую причину изменения (включая пришедшие от родителя) и опаздывает на рендер. Помести вызов в обработчик, вызвавший изменение, где новое значение тебе уже известно. Второй — цепочки апдейтов: установка состояния A в эффекте, следящем за состоянием B, которое ставит C в эффекте, следящем за A, — это каскад лишних проходов рендера, который невозможно отследить. Вычисли всё следующее состояние в обработчике и поставь его в одном месте.

// ❌ эффект зеркалит изменение наверх к родителю — лишний рендер, срабатывает на любом пути
function Toggle({ onChange }: { onChange: (on: boolean) => void }) {
  const [on, setOn] = useState(false);
  useEffect(() => { onChange(on); }, [on]); // да и onChange даже нет в зависимостях
  return <button onClick={() => setOn((v) => !v)}>{on ? "On" : "Off"}</button>;
}

// ✅ делай обе вещи в одном месте, где есть новое значение: в обработчике
function Toggle({ onChange }: { onChange: (on: boolean) => void }) {
  const [on, setOn] = useState(false);
  function handleClick() {
    const next = !on;
    setOn(next);
    onChange(next); // родитель узнаёт сразу, в том же событии
  }
  return <button onClick={handleClick}>{on ? "On" : "Off"}</button>;
}

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

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

Компонент результатов поиска, до и после чистки. Он загружает результаты по запросу, отдаёт родителю «число результатов» и сбрасывает скролл/выбор при смене запроса. Первая версия выражает все три через эффекты.

// ❌ до: три эффекта, два из них ненужны и багованы
function Results({ query, onCount }: { query: string; onCount: (n: number) => void }) {
  const [items, setItems] = useState<Item[]>([]);
  const [sorted, setSorted] = useState<Item[]>([]);
  const [selected, setSelected] = useState<string | null>(null);

  // (1) настоящий: загрузка с сервера при смене запроса
  useEffect(() => {
    let active = true;
    fetchResults(query).then((r) => { if (active) setItems(r); });
    return () => { active = false; };
  }, [query]);

  // (2) ненужный: производный список, зеркалённый в состояние
  useEffect(() => { setSorted([...items].sort(byScore)); }, [items]);

  // (3) ненужный: уведомить родителя + сбросить выбор из эффекта
  useEffect(() => { onCount(sorted.length); }, [sorted]);
  useEffect(() => { setSelected(null); }, [query]); // вспышка старого выбора

  return <List items={sorted} selected={selected} onPick={setSelected} />;
}

Эффект (2) выводим, уведомление эффекта (3) принадлежит туда, куда приходят данные, а сброс выбора принадлежит идентичности — key. После чистки остаётся ровно один эффект, и это единственный, кто трогает внешний мир.

// ✅ после: один настоящий эффект; остальное становится рендером + key
function Results({ query, onCount }: { query: string; onCount: (n: number) => void }) {
  const [items, setItems] = useState<Item[]>([]);

  useEffect(() => {                       // ЕДИНСТВЕННЫЙ законный эффект: синхрон с сервером
    let active = true;
    fetchResults(query).then((r) => {
      if (!active) return;
      setItems(r);
      onCount(r.length);                  // уведомляем ровно тогда, когда приходят данные
    });
    return () => { active = false; };
  }, [query]);

  const sorted = useMemo(() => [...items].sort(byScore), [items]); // выводим в рендере

  return <List items={sorted} onPick={() => {}} />; // сброс выбора обрабатывается через key
}

// выбор сбрасывается на каждый query, потому что меняется идентичность → свежий экземпляр
<Results key={query} query={query} onCount={setCount} />;

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

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

Опасная перекоррекция — удалить настоящий эффект внешней синхронизации, потому что он поверхностно похож на выводимое состояние. Подписка на стор, навешивание DOM-слушателя события, запуск таймера, открытие WebSocket или императивная синхронизация не-React-виджета (карта, чарт, видеоплеер) — это подлинные эффекты: данные живут вне React, и их надо втянуть и потом снести. Признак — функция очистки: если работе нужна разборка (отписаться, clearInterval, закрыть сокет), это синхронизация, а не вывод, и переписывания через const/useMemo/key тут не применимы. Удаляй эффекты, заново выводящие то, что может вычислить рендер; оставляй эффекты, дотягивающиеся за пределы мира React.

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

Почему версия с эффектом не просто многословнее, а реально багованнее? Потому что эффект запускается после того, как рендер зафиксирован. Так что всегда есть хотя бы один отрисованный кадр, где состояние-источник обновилось, а производное — нет: вспышка протухшего кадра. Есть двойной рендер (поставить источник → коммит → эффект → поставить производное → коммит снова), удваивающий работу на горячем пути. И есть массив зависимостей, который молча протухает, когда забываешь зависимость (вроде onChange), и выдаёт неверные значения без всякой ошибки. Вычисление в рендере обходит все три: нет «после», нет второго коммита и нет списка зависимостей, который мог бы рассинхронизироваться.

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

Компонент подписывается на браузерное событие online/offline через addEventListener в useEffect, хранит статус в состоянии и снимает слушателя в очистке. Ревьюер говорит: «возможно, эффект тебе не нужен — выведи его в рендере». Кто прав?

Итог

Большинство эффектов в реальном коде не нужны, а ненужные эффекты — это не просто мусор: они вызывают вспышки протухших кадров, двойные рендеры и баги протухших замыканий. Каталог на удаление: производное состояние и преобразования для отображения (вычисляй в рендере; тянись за useMemo, только когда требует профилирование), сброс состояния при смене пропса (дай поддереву key, чтобы React перемонтировал свежий экземпляр), уведомление родителя или цепочки апдейтов (делай это в обработчике события, у которого уже есть новое значение). Единственный вопрос, который их сортирует: общается ли этот эффект с системой вне React? Если нет — он заново выводит данные, и не-эффектный инструмент делает это раньше и надёжнее. Если да — сеть, DOM, подписки, таймеры, не-React-виджеты, что угодно с настоящей разборкой — это синхронизация, и ты её оставляешь. Senior-навык — держать оба края: агрессивно удалять выводимые эффекты и никогда не принимать подлинный эффект внешней синхронизации за один из них.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.