Оптимизация производительности React: глубокое погружение в memo, useMemo и useCallback

Отзывчивый UI — не просто улучшение UX, а требование современных веб-приложений. React автоматически управляет обновлениями интерфейса, но эта магия порой оборачивается неоправданно частыми ререндерами компонентов. Мы разберем инструменты оптимизации в экосистеме React, выходящие за рамки базового применения.

Проблема избыточных ререндеров
Рассмотрим компонент ProductList, отображающий 1000 товаров:

jsx
const ProductList = ({ products, onSelect }) => (
  <ul>
    {products.map((product) => (
      <ProductItem 
        key={product.id} 
        product={product} 
        onClick={() => onSelect(product.id)}
      />
    ))}
  </ul>
);

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

Факторы триггеров ререндеров:

  • Изменение пропсов (даже объект с идентичными полями)
  • Обновление состояния (useState, useReducer)
  • Обновление контекста (useContext)

Мемоизация компонентов с React.memo
React.memo кэширует результат рендера, повторный ререндер происходит только при изменении пропсов (по shallow compare). Модифицируем ProductItem:

jsx
const ProductItem = React.memo(({ product, onClick }) => {
  // Тяжелые вычисления
  const discountedPrice = calculateDiscount(product.price, 15); 
  
  return (
    <li onClick={onClick}>
      {product.name} - ${discountedPrice}
    </li>
  );
});

Теперь ProductItem ререндерится только при изменении product или onClick. Но здесь кроется ловушка: () => onSelect(product.id) при каждом рендере родителя создаёт новую функцию, что приводит к ререндерам всех элементов!

Фиксация колбэков через useCallback
Перепишем обработчик с использованием useCallback:

jsx
const ProductList = ({ products, onSelect }) => {
  const handleSelect = useCallback((id) => {
    onSelect(id);
  }, [onSelect]); // Зависимость обязательна
  
  return (
    <ul>
      {products.map((product) => (
        <ProductItem 
          key={product.id} 
          product={product} 
          onClick={handleSelect} 
        />
      ))}
    </ul>
  );
};

useCallback мемоизирует ссылку на функцию. Все ProductItem теперь получают стабильный onClick, ререндер происходит только при реальном изменении product.

useMemo: кэш для тяжёлых вычислений
Вернемся к calculateDiscount внутри ProductItem. Вычисление скидки запускается при каждом рендере, даже если цена не изменилась. Исправляем:

jsx
const ProductItem = React.memo(({ product, onClick }) => {
  const discountedPrice = useMemo(() => {
    return calculateDiscount(product.price, 15);
  }, [product.price]); // Единственная зависимость

  return <li onClick={() => onClick(product.id)}>{product.name} - ${discountedPrice}</li>;
});

useMemo кэширует значение. Вычисления происходят только при изменении product.price.

Глубокое сравнение пропсов
Что если product — сложный объект с вложенными полями? Shallow compare в React.memo не заметит изменения во внутренних свойствах. Возможны два решения:

jsx
// Кастомная функция сравнения
React.memo(ProductItem, (prevProps, nextProps) => {
  return prevProps.product.id === nextProps.product.id 
    && prevProps.product.price === nextProps.product.price;
});

// Или нормализация данных: передача примитивов
<ProductItem 
  id={product.id} 
  price={product.price} 
  name={product.name} 
/>

Когда оптимизация становится антипаттерном:

  • Компоненты с частым изменением пропсов (кэш никогда не используется)
  • Использование useMemo/useCallback для примитивов
  • Мемоизация JSX блоков без реальных вычислений
  • Избыточная вложенность мемоизированных компонентов

Типичная ошибка с зависимостями
Неправильное применение зависимостей сводит оптимизацию на нет:

jsx
const totalPrice = useMemo(() => (
  products.reduce((sum, p) => sum + p.price, 0)
), []); // ❌ Зависимости пусты — значение никогда не обновится

const handleSelect = useCallback((id) => (
  setSelectedIds(prev => [...prev, id])
), []); // ✅ Корректно: функция не зависит от внешних значений

Профилирование производительности
Без данных все оптимизации слепы. Используйте:

  • React DevTools Profiler (замеры времени рендера)
  • Вкладка Performance в Chrome DevTools
  • Why Did You Render для отслеживания избыточных ререндеров
  • React.memo с выводом в консоль при ререндере

Практические рекомендации:

  1. Сначала пишите без оптимизаций, замеряйте профилировщиком
  2. Мемоизируйте компоненты с тяжёлым рендером (списки, графики)
  3. useCallback — для функций, передаваемых в редко обновляемые компоненты
  4. useMemo — для дорогих вычислений при каждом рендере
  5. Нормализуйте данные на границах компонентов
  6. Избегайте ререндеров контекста мемоизацией провайдера

Оптимизация — баланс между производительностью и сложностью кода. Бездумная мемоизация усложнит логику без заметных улучшений. Начинайте с горячих точек, выявленных объективными замерами. Современные API React предоставляют точные инструменты для контроля рендеринга — применяйте их точечно и осмысленно.