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

Выводи, а не храни

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

RXP Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

У тебя в состоянии лежат firstName и lastName, а нужен fullName. И ты добавляешь третий useState и эффект, который записывает в него firstName + " " + lastName при каждом изменении любого из двух. Это работает — пока какой-то путь не выставит firstName до того, как эффект успел отработать, и на один рендер экран не покажет имя, наполовину старое, наполовину новое. Ты создал баг, который существует только потому, что значение хранилось.

Лечится это не более хорошим эффектом. Лечится это удалением состояния целиком. fullName никогда не был состоянием — он был функцией от состояния, а место, где считают функцию от состояния, — это рендер. Этот урок — про самый частый антипаттерн useEffect и про однострочную дисциплину, которая его убирает.

Цель

После этого урока ты можешь отличить производные значения от состояния и тянуться к нужному рефлекторно; заменить «зеркало» из useState + useEffect на простую const, вычисленную во время рендера; считать отфильтрованные/отсортированные/сгруппированные данные в рендере, а не хранить их; объяснить, почему хранимое производное состояние — это баг с двумя источниками истины, ждущий расхождения; и знать два способа всё испортить — отрефлекторно мемоизировать тривиальный вывод (чистый шум) и более редкую ошибку — счесть по-настоящему дорогой кешированный результат за переусложнение.

1

Если ты можешь вычислить это из существующих пропсов или состояния — это не состояние, это производное. Состояние — минимальный набор значений, которые React не может воссоздать сам: пользовательский ввод, ответы сервера, вещи со своим жизненным циклом. Всё остальное — функция от этого минимального набора. Тест — один вопрос: имея пропсы и остальное состояние, могу ли я всегда пересчитать это? Если да, ему не место в useState.

// firstName и lastName — настоящее состояние (пользователь их печатает).
// fullName — НЕТ: он всегда пересчитывается из этих двух.
const [firstName, setFirstName] = useState("Ada");
const [lastName, setLastName] = useState("Lovelace");
const fullName = `${firstName} ${lastName}`; // производное, в рендере

fullName обновляется в тот же миг, как меняется любой из вводов, потому что пересчитывается каждый рендер. Нет второго значения, которое надо держать синхронным, потому что второго значения нет вообще.

2

Зеркалирование одного состояния в другое через эффект создаёт два источника истины, которые расходятся. В момент, когда fullName оседает в своём useState, истина («каким должно быть имя») существует в двух местах — а эффект становится твоим хрупким обещанием держать их равными. Это обещание ломается на самых обычных путях: эффект исполняется после отрисовки, поэтому первый рендер показывает устаревшие данные (мелькание); сброс, который выставляет firstName, но затем перезаписывается, оставляет зеркало устаревшим; и ты добавил лишний рендер на каждое изменение, потому что setFullName в эффекте планирует второй проход.

// АНТИПАТТЕРН: второй источник истины, синхронизируемый вручную
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(`${firstName} ${lastName}`); // лишний рендер + может отставать от реальности
}, [firstName, lastName]);

Каждый баг здесь — баг синхронизации, а баги синхронизации исчезают, когда истинной должна быть лишь одна вещь. Удаление состояния удаляет весь этот класс отказа.

3

Отфильтрованные, отсортированные и сгруппированные списки тоже производные — считай их в рендере, не храни. Тот же рефлекс, что добавляет состояние fullName, добавляет состояние visibleTodos с эффектом, который перефильтровывает при каждом изменении todos или filter. У него тот же изъян: отфильтрованный список может отставать от реального на рендер, и ты платишь лишним рендером каждый раз, когда двигается любой из вводов. Отфильтрованный список — это f(todos, filter), так и пиши его.

// выводим во время рендера — всегда согласовано со входами
const visibleTodos = todos
  .filter((t) => filter === "all" || t.status === filter)
  .sort((a, b) => a.createdAt - b.createdAt);

Ни эффекта, ни лишнего состояния, ни лишнего рендера, и visibleTodos никогда не рассинхронизирован с todos или filter — потому что он буквально выводится из них на месте.

4

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

// оправдано: вход большой, работа настоящая
const sorted = useMemo(
  () => bigList.slice().sort(expensiveComparator),
  [bigList],
);

Главное: useMemo всё ещё выводит в рендере — это тот же паттерн, просто кешированный. Это не возврат к хранению состояния. У значения по-прежнему один источник истины; ты лишь мемоизировал его вычисление.

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

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

function Profile({ todos }: { todos: Todo[] }) {
  const [firstName, setFirstName] = useState("Ada");
  const [lastName, setLastName] = useState("Lovelace");
  const [filter, setFilter] = useState<Filter>("all");

  // БАГ №1: fullName зеркалится — мелькает устаревшим, лишний рендер
  const [fullName, setFullName] = useState("");
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  // БАГ №2: visibleTodos зеркалится — может отставать от todos/filter на рендер
  const [visibleTodos, setVisibleTodos] = useState<Todo[]>([]);
  useEffect(() => {
    setVisibleTodos(todos.filter((t) => filter === "all" || t.status === filter));
  }, [todos, filter]);

  return <Panel name={fullName} items={visibleTodos} /* … */ />;
}

Оба эффекта — один и тот же антипаттерн: useState, держащий нечто, уже определённое другим состоянием, плюс эффект, чтобы держать его равным. Удали оба. Значения становятся простыми const, вычисленными во время рендера:

function Profile({ todos }: { todos: Todo[] }) {
  const [firstName, setFirstName] = useState("Ada");
  const [lastName, setLastName] = useState("Lovelace");
  const [filter, setFilter] = useState<Filter>("all");

  // производное: один источник истины, никогда не устаревает, без лишнего рендера
  const fullName = `${firstName} ${lastName}`;
  const visibleTodos = todos.filter((t) => filter === "all" || t.status === filter);

  return <Panel name={fullName} items={visibleTodos} /* … */ />;
}

Два useState ушли, два эффекта ушли, по два прохода рендера сэкономлено на каждое изменение, и две целые категории бага расхождения удалены. visibleTodos здесь — небольшой фильтр, поэтому он остаётся простой const: обернуть его в useMemo было бы тем самым шумом рефлекторной мемоизации из Шага 4. Если бы todos были десятками тысяч строк с тяжёлой сортировкой, тогда useMemo(() => …, [todos, filter]) заслужил бы своё место — всё ещё выведенный в рендере, просто кешированный.

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

Почему «выводи, а не храни» — правило с наибольшим рычагом в гигиене эффектов? Потому что эффекты, синхронизирующие состояние React с другим состоянием React, — самое частое злоупотребление useEffect, и почти все они схлопываются в вывод. Эффекты существуют, чтобы синхронизироваться с системами вне React — DOM, подпиской, сетью. Зеркалирование состояния в состояние происходит внутри React, где эффект не нужен вовсе; ты просто вычисляешь. Убрать его — не только меньше строк: это убирает рендер, убирает мелькание устаревшего кадра и убирает массив зависимостей, который иначе пришлось бы держать корректным вечно.

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

Рефлекторная ошибка идёт в другую сторону, едва люди выучат это правило: оборачивать каждую производную const в useMemo «на всякий случай». Конкатенация строки и фильтр по 200 элементам пересчитываются за микросекунды; useMemo там добавляет массив зависимостей, замыкание и накладные на чтение ради нуля измеримой выгоды — это карго-культ производительности. Мемоизируй, когда выявил реальную стоимость (большие данные, дорогая трансформация) или когда нужна ссылочная стабильность для memo-нутого потомка или зависимости эффекта. Более редкая обратная ошибка — ложная тревога: увидеть useMemo и «упростить» его до простой const, когда он на самом деле защищал по-настоящему дорогое вычисление или стабильную ссылку. Прочитай, почему мемо существует, прежде чем его убирать.

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

Компонент держит selectedId в состоянии и, чтобы показать выбранную строку, держит второе состояние selectedRow, синхронизируемое эффектом: useEffect(() => setSelectedRow(rows.find(r => r.id === selectedId)), [rows, selectedId]). Каков senior-фикс?

Итог

Значение, которое ты можешь пересчитать из существующих пропсов или состояния, — производное, а не состояние: считай его во время рендера простой const, никогда не зеркаль во второй useState, синхронизируемый через useEffect. Зеркалирование раздваивает истину на два места, а эффект — хрупкое рукописное обещание держать их равными: он мелькает устаревшим на первом рендере, может отставать от реальности на обычных путях кода и стоит лишнего рендера на каждое изменение. Удаление состояния удаляет весь класс бага синхронизации, потому что истинной остаётся лишь одна вещь. Это в той же мере касается отфильтрованных, отсортированных и сгруппированных списковf(todos, filter) принадлежит рендеру, а не состоянию visibleTodos. Тянись к useMemo только когда вывод по-настоящему дорог или нуждается в стабильной ссылке; он всё равно выводит в рендере, просто кешированно — это не лицензия снова хранить производное состояние. И сопротивляйся двум рефлексам: мемоизации тривиальных выводов (шум) и, реже, «упрощению» мемо, защищавшего реальную стоимость. Дисциплина в одной строке: если можешь вывести — не храни.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.