Реклама
Меморина
Меморина
Меморина

React-паттерн, который все используют, но он убивает производительность

Inline-объекты и колбэки в JSX кажутся безобидными, но при memoизации они превращаются в узкое горло. Показываем, почему React сравнивает пропсы по ссылке, и как восстановить bailout.

Обложка: React-паттерн, который все используют, но он убивает производительность

Вы обернули список в React.memo, добавили useCallback на обработчики и ждёте, что интерфейс полетит. Но при каждом вводе в поисковую строку список всё равно тормозит, а Profiler показывает 200 лишних рендеров. Чаще всего виноват не React, а один крошечный JSX-паттерн, который встречается в каждом втором компоненте.

В статье разбираем, почему React.memo сравнивает пропсы по ссылке, как inline-объекты и inline-функции обнуляют эту оптимизацию, и какие два простых приёма действительно возвращают производительность.

Ключевые выводы

React.memo пропускает рендер только тогда, когда все пропсы равны по Object.is. Для объектов и функций это проверка по ссылке.

Inline-объекты и inline-колбэки в JSX создают новую ссылку на каждый рендер родителя, поэтому memo видит «новые» пропсы и снова рисует дочерний компонент.

В эксперименте со 200 memoизированными строками это дало 243,9 мс на один keystroke; после стабилизации ссылок — 6 мс.

Сначала выносите статические объекты за пределы компонента, а useCallback применяйте только там, где колбэк действительно пересекается с memoизированным потомком.

Оптимизировать всё подряд не нужно: измеряйте в Profiler, а не добавляйте хуки «на всякий случай».

Как React решает, рендерить компонент заново или нет

Когда родитель перерисовывается, React не делает послаблений дочерним элементам только потому, что они обёрнуты в React.memo. Он сравнивает новые пропсы со старыми. Если каждый проп проходит проверку Object.is, React может «отказаться» от рендера — bailout. Если хотя бы один проп не равен, компонент рисуется заново.

Для примитивов Object.is работает очевидно: 1 === 1 и 'hello' === 'hello'. А вот для объектов и функций сравнение идёт по ссылке.

			Object.is({ padding: 16 }, { padding: 16 }); // false
Object.is(() => {}, () => {});             // false
		

Два объекта с одинаковым содержимым — это разные объекты в памяти. То же самое с функциями. Поэтому, когда вы пишете style=\{\{ padding: 16 \}\}, React получает новую ссылку на каждом рендере и считает проп изменившимся.

Почему inline-пропсы — это не микрооптимизация, а поломка контракта

Сам по себе объект в JSX не вреден. Если компонент дешёвый, редко перерисовывается и не обёрнут в memo, inline-пропсы почти не влияют на скорость. Проблема появляется, когда три условия накладываются друг на друга:

  • родитель перерисовывается часто — поиск, скролл, фильтры, live-данные;
  • дочерний компонент или поддерево достаточно тяжёлые, чтобы лишний рендер был заметен;
  • вы уже добавили React.memo и ожидаете, что React будет пропускать работу.

В такой ситуации нестабильные ссылки не просто добавляют накладных расходов — они полностью отменяют ту оптимизацию, ради которой вы взяли React.memo. UI продолжает работать, но лагает ввод, тормозят списки, а в Profiler видно, что дерево горит жёлтым при каждом чихе.

Классический опасный пример

Представьте список товаров из 200 строк. Каждая строка обёрнута в memo, но в месте вызова передаются inline-пропсы:

			{filteredProducts.map(product => (
  <ProductRow
    key={product.id}
    product={product}
    style={{
      display: 'flex',
      justifyContent: 'space-between',
      padding: '12px 20px',
      borderBottom: '1px solid #eee'
    }}
    onAddToCart={(id) => addToCart(id)}
  />
))}
		

Здесь style и onAddToCart создаются заново при каждом рендере ProductList. Для memo это сигнал, что у каждой строки изменились пропсы, и все 200 компонентов рисуются снова.

Эксперимент: от 243,9 мс до 6 мс на один keystroke

Автор оригинальной статьи собрал контрольный пример: поисковый интерфейс со 200 memoизированными строками. Все строки получают одинаковые логические значения, но новые ссылки на объекты и функции. Результат после шести введённых символов: каждая видимая строка отрендерилась 14 раз.

В React DevTools Profiler один keystroke дал коммит длительностью 243,9 мс, в котором подсветились все 200 волокон строк. Инструмент @welldone-software/why-did-you-render прямо указал причину: props.style — «different objects that are equal by value», props.onAddToCart — «different functions with the same name».

Почему 14 рендеров?
Каждый ввод в поиск меняет searchTerm, родитель перерисовывается, и inline-пропсы дают строкам новые ссылки. Счётчик рендеров растёт на каждый keystroke, даже если отфильтрованные товары не изменились.

Как починить: два приёма вместо дюжины хуков

Чтобы восстановить bailout, нужно сделать так, чтобы неизменяющиеся значения не получали новую ссылку на каждом рендере. Правило простое: сначала вынести, потом закешировать.

1. Статические объекты — за пределы компонента

Если объект не зависит от пропсов и состояния, создайте его один раз на уровне модуля. Это дешевле любого хука и не требует dependency-массива.

			const ROW_STYLE = {
  display: 'flex',
  justifyContent: 'space-between',
  padding: '12px 20px',
  borderBottom: '1px solid #eee'
};

function ProductList() {
  const [searchTerm, setSearchTerm] = useState('');
  // ...
  return (
    <>
      {filteredProducts.map(product => (
        <ProductRow
          key={product.id}
          product={product}
          style={ROW_STYLE}
        />
      ))}
    </>
  );
}
		

2. Динамические колбэки — useCallback

Если функция передаётся в memoизированный компонент и не должна меняться без причины, оберните её в useCallback со стабильным массивом зависимостей.

			function ProductList() {
  const [searchTerm, setSearchTerm] = useState('');

  const handleAddToCart = useCallback((id) => {
    addToCart(id);
  }, []);

  return (
    <>
      {filteredProducts.map(product => (
        <ProductRow
          key={product.id}
          product={product}
          style={ROW_STYLE}
          onAddToCart={handleAddToCart}
        />
      ))}
    </>
  );
}
		

После этих двух изменений в эксперименте время рендера упало с 243,9 мс до 6 мс, счётчики строк застыли на 2, а Why Did You Render замолчал — avoidable re-renders исчезли.

Когда не нужно ничего стабилизировать

Главная ошибка — оборачивать в useCallback каждую функцию и выносить каждый объект за компонент. React сам по себе быстрый, а мемоизация — это контракт, а не стиль кодирования.

  • Компонент дёшев и редко перерисовывается — не тратьте когнитивный бюджет команды.
  • Дочерний элемент не обёрнут в React.memo — тогда стабильные ссылки ничего не экономят.
  • Значение зависит от часто меняющегося состояния — useCallback с нестабильным массивом зависимостей всё равно будет пересоздаваться.
  • Вы ещё не замерили в Profiler — оптимизация без измерений почти всегда лишняя работа.

React Compiler, который сейчас выходит в стабильное состояние, автоматически мемоизирует многое из того, что раньше делали вручную. Но и он не отменяет понимания ссылочной стабильности: useMemo и useCallback остаются полезными, когда нужен точный контроль, например для зависимостей эффектов.

Часто задаваемые вопросы

Часто задаваемые вопросы
1
Почему React не сравнивает пропсы по значению?

Глубокое сравнение объектов на каждый рендер обошлось бы дороже, чем сам рендер большинства компонентов. Object.is — это быстрая проверка идентичности, которая ложится в основу bailout. Если нужно сравнение по содержимому, разработчик сам решает, какую семантику считать равенством.

2
Можно ли просто не использовать React.memo?

Можно, если нет заметных проблем с производительностью. Но в больших списках, дашбордах и поисковых интерфейсах memo спасает от лишней работы — при условии, что пропсы стабильны.

3
А если объект зависит от состояния?

Используйте useMemo, но только если измерения показывают пользу. Если объект не передаётся в memoизированный потомок, часто проще оставить inline-объект.

4
Почему useCallback с пустым массивом зависимостей не ломает замыкания?

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

5
React Compiler решит эту проблему полностью?

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

Выводы

Inline-объекты и inline-колбэки в JSX — не антипаттерн сами по себе. Они становятся проблемой только на границе memoизации, где React ожидает стабильные ссылки. Как только вы понимаете это правило, многие «таинственные» лишние рендеры перестают быть таинственными.

  1. Профилируйте до оптимизации, а не после.
  2. Вынесите статические объекты за компонент — это самый дешёвый способ стабилизации.
  3. Применяйте useCallback только для колбэков, которые уходят в memoизированные потомки.
  4. Проверяйте себя через React DevTools Profiler и Why Did You Render.
  5. Не забывайте про React Compiler, но не полагайтесь на него как на волшебную палочку.
Мемоизация — это контракт. Если потомок рассчитывает на стабильные ссылки, родитель должен их обеспечить. Нарушение этого контракта стоит намного дороже, чем отсутствие мемоизации вовсе.
Авторская позицияTproger

Источник: LogRocket — The React pattern everyone uses that kills performance.

Проверьте свой текущий проект: откройте React DevTools Profiler, введите что-нибудь в поиск и посмотрите, сколько компонентов подсветится жёлтым только из-за новой ссылки в пропсах.