Оптимизация производительности React: разумное использование useMemo и useCallback

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

Проблема лишних ререндеров

Рассмотрим типичную ситуацию: компонент, который регулярно пересчитывает «тяжёлые» значения или передаёт новые колбэки дочерним элементам. Хотя React обновляет DOM только при реальных изменениях, виртуальный диффинг и вызовы функций рендеринга потребляют ресурсы. Давайте смоделируем проблему:

jsx
const UserList = ({ users, sortOrder }) => {
  const sortedUsers = users.sort((a, b) => 
    sortOrder === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
  );
  
  return (
    <ul>
      {sortedUsers.map(user => (
        <UserItem 
          key={user.id} 
          user={user} 
          onSelect={() => handleSelect(user.id)}
        />
      ))}
    </ul>
  );
};

Здесь две серьезные проблемы:

  1. sort() мутирует массив прямо в рендере и создает новую ссылку при каждом рендере
  2. Анонимная функция () => handleSelect(user.id) гарантированно создается заново для каждого рендера

Результат: даже если users и sortOrder не меняются, любой родительский ререндер заставит UserItem перерисовываться, потому что он получает новые пропсы — функцию onSelect и в некоторых случаях user (из-за мутации массива).

Глубокое погружение в useMemo

useMemo мемоизирует значения:

jsx
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

Логика:

  • Первый рендер: вычисляет функцию и возвращает результат
  • Последующие рендеры: возвращает кешированное значение, если зависимости не изменились
  • При изменении зависимостей — заново вычисляет и сохраняет результат

Оптимизируем наш пример с сортировкой:

jsx
const sortedUsers = useMemo(() => {
  // Создаем копию перед сортировкой чтобы избежать мутаций
  return [...users].sort((a, b) => 
    sortOrder === 'asc' 
      ? a.name.localeCompare(b.name) 
      : b.name.localeCompare(a.name)
  );
}, [users, sortOrder]);

Что достигнуто:

  • Сложная операция сортировки не выполняется без нужды
  • Дочерние компоненты получают стабильную ссылку на массив при неизменных зависимостях
  • Предотвращена мутация исходных данных

Критически важные детали:

  1. useMemo кеширует результат вычислений, а не саму функцию
  2. Зависимости должны включать все значения, используемые внутри колбэка
  3. Бессмысленная мемоизация примитивов (const count = useMemo(() => 5, [])) — антипаттерн
  4. Пользуйтесь фабричной функцией только для ресурсоёмких вычислений (> 1ms)

Подлинное назначение useCallback

useCallback хранит стабильную функцию между рендерами:

jsx
const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

Эквивалентно:

jsx
useMemo(() => () => doSomething(a, b), [a, b]);

Исправим проблему с инлайн-функцией в UserList:

jsx
const UserList = ({ users, sortOrder }) => {
  // Подготовка...
  
  const handleSelect = useCallback((userId) => {
    // Логика обработки
  }, []); // Зависимости пусты если логика не зависит от внешних переменных

  return (
    <ul>
      {sortedUsers.map(user => (
        <UserItem 
          key={user.id} 
          user={user} 
          onSelect={handleSelect} 
        />
      ))}
    </ul>
  );
};

Что достигнуто:

  • onSelect становится стабильной ссылкой между рендерами
  • UserItem не ререндерится до изменения своих реальных пропсов

Опасные подводные камни:

jsx
// Смертельный грех: забытые зависимости
const handleSubmit = useCallback(() => {
  updateData(formData);
}, []); // formData всегда будет устаревшей!

Исправление:

jsx
const [formData, setFormData] = useState();

const handleSubmit = useCallback(() => {
  updateData(formData);
}, [formData, updateData]); // Корректные зависимости

Когда оптимизация не нужна

Прагматизм важнее догм. Мемоизация избыточна если:

  • Вычисление тривиальное (математика с примитивами)
  • Компонент рендерится редко
  • Дочерние компоненты простые (span, div, button)
  • Объекты и функции передаются только компонентам которые не memoized

Таксономия компонентов через React.memo

Фундаментальное дополнение к useCallback и useMemoReact.memo. Он предотвращает ререндер дочернего компонента до изменения пропсов.

jsx
const UserItem = React.memo(({ user, onSelect }) => {
  // ...
});

Только в сочетании с передачей мемоизированных пропсов React.memo работает эффективно. Без useCallback для onSelect и useMemo для user наша оптимизация теряет смысл — пропсы будут каждый раз различными.

Реальные бенчмарки

Общее правило: измеряйте производительность до и после. Создадим синтетическое сравнение в DevTools:

jsx
// До оптимизации: 150 мс рендера
const RenderTest = () => {
  const [state, setState] = useState(0);
  
  return (
    <>
      <button onClick={() => setState(prev => prev + 1)}>Render</button>
      <UnoptimizedComponent data={largeArray} />
    </>
  );
};

// После: 35 мс
const OptimizedTest = () => {
  // ...используем useMemo, useCallback и React.memo
};

Профилирование в React DevTools показывает 74% сокращение времени рендера компонента листа.

Архитектурные решения

В комплексных приложениях важнее архитектурные практики:

  • Локализация состояния: держите state максимально близко к потребителям
  • Применение children для предотвращения проблем с пропсами:
    jsx
    <Modal>
      <ComplexContent /> {/* Не ререндерит при обновлении Modal */}
    </Modal>
    
  • Разделение на «умные» и «глупые» компоненты
  • Виртуализация списков для DOM с тысячами элементов

useMemo и useCallback — не универсальное решение, а хирургический инструмент в грамотно организованной структуре.

Резюмируя: практические тактики

  1. Начинайте без оптимизаций — добавите их при профилировании
  2. Используйте useMemo для:
    • Тяжёлых инструментальных вычислений (фильтрация, сортировка)
    • Стабильных ссылок на объекты/массивы
  3. Используйте useCallback для:
    • Передачи колбэков в memoized компоненты
  4. Всегда проверяйте зависимости в хуках и реакт-компонентах
  5. Комбинируйте с React.memo для выборочного подавления ререндеров
  6. Используйте useReducer для сложных апдейтов, когда функции зависят от деструктуризации множества зависимостей
  7. Контролируйте пропсы объекта через {...props} — это гарантирует новую ссылку!

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