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

Современные React-приложения страдают от незаметной на первый взгляд проблемы: компоненты перерисовываются чаще, чем необходимо. Неоптимизированные ререндеры снижают FPS, расходуют заряд батареи и ухудшают пользовательский опыт, особенно на слабых устройствах. Рассмотрим реальное решение через мемоизацию.

Почему лишние ререндеры возникают?

React перерисовывает компонент в трёх случаях:

  1. Изменились пропсы
  2. Изменилось состояние компонента
  3. Изменился родительский компонент

Проблема возникает, когда дочерние компоненты обновляются из-за изменений, которые на них не влияют. Классический пример:

jsx
const ParentComponent = () => {
  const [count, setCount] = useState(0);
  
  const expensiveOperation = () => {
    // Тяжёлые вычисления
    return complexDataProcessing();
  };

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Increment: {count}</button>
      <ChildComponent processor={expensiveOperation} />
    </>
  );
};

const ChildComponent = React.memo(({ processor }) => {
  const result = processor();
  return <div>{result}</div>;
});

Каждое нажатие кнопки:

  1. Меняет состояние count в ParentComponent
  2. Вызывает ререндер родителя
  3. Создаёт новую ссылку функции expensiveOperation
  4. Передаёт новую ссылку в ChildComponent
  5. React.memo видит новую функцию-пропс ⇒ заставляет ChildComponent обновиться

Результат: дорогостоящая операция запускается на каждый клик, хотя её результат не изменился.

Разбираем инструменты мемоизации

useMemo: Замороженные значения

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

Принцип работы:

  • Кэширует результат вычислений
  • Пересчитывает только при изменении зависимостей
  • Возвращает одинковую ссылку на объект при неизменных зависимостях

Исправляем предыдущий пример:

jsx
const ParentComponent = () => {
  const [count, setCount] = useState(0);
  
  const expensiveResult = useMemo(() => {
    return complexDataProcessing();
  }, []); // Пустой массив = вычисляем один раз при монтировании

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Increment: {count}</button>
      <ChildComponent data={expensiveResult} />
    </>
  );
};

useCallback: Стабильные ссылки функций

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

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

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

Фиксируем проблему с передачей функции:

jsx
const ParentComponent = () => {
  const [count, setCount] = useState(0);
  
  const expensiveOperation = useCallback(() => {
    return complexDataProcessing();
  }, []);
  
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Increment: {count}</button>
      <ChildComponent processor={expensiveOperation} />
    </>
  );
};

Теперь ChildComponent не будет ререндериться при кликах — функция сохраняет стабильную ссылку.

Когда мемоизация не работает: Типичные ловушки

1. Неправильный контроль зависимостей

jsx
const UserProfile = ({ userId }) => {
  const fetchUser = useCallback(async () => {
    const res = await fetch(`/api/users/${userId}`);
    return res.json();
  }, []); // ❌ Забыли userId в зависимостях
};

Функция будет использовать первоначальное значение userId. Правильно: [userId].

2. Преждевременная оптимизация

Не нужно оборачивать всё подряд:

jsx
const simpleCalculation = useMemo(() => 2 + 2, []); // ❌ Избыточно
const onClick = useCallback(() => setVisible(true), []); // ❌ Избыточно

Оптимизируйте только:

  • Тяжёлые вычисления (фильтрация массивов, преобразования данных)
  • Функции, передаваемые в чистые компоненты (React.memo)
  • Функции внутри эффектов

3. Бесполезная мемоизация внутренних компонентов

jsx
const HeavyComponent = () => {
  const internalFunction = useCallback(() => {...}, []);
  
  return <InternalRenderer handler={internalFunction} />;
};

Если InternalRenderer не обёрнут в React.memo, оптимизация ничего не даст — компонент будет перерисовываться по любым причинам.

4. Лишние зависимости в эффектах

jsx
const [state, setState] = useState();
const stableFn = useCallback(() => {...}, []);

useEffect(() => {
  stableFn(); // ❌ ESLint заставит добавить stableFn в зависимости
}, [stableFn]);

Решение: Объявить функцию внутри эффекта или использовать ссылочную стабильность через useEvent (экспериментально) либо рефы.

Как разрабатывать эффективно?

Диагностируйте проблему

  1. DevTools Profiler — записывайте и анализируйте ререндеры
  2. React DevTools — включайте подсчёт ререндеров компонентов
  3. Консольные логи в теле компонента — простейший индикатор
jsx
const MyComponent = () => {
  console.log('Component rendered'); // Простейший детектор
  // ...
};

Правило трёх шагов для оптимизации

  1. Профилируйте приложение на реальных сценариях
  2. Находите узкие места с помощью Flamegraph и Rankings в Profiler
  3. Применяйте адресную оптимизацию только к проблемным компонентам

Альтернатива useCallback с рефами

Для функций, вызываемых в эффектах, где зависимости контролировать сложно:

jsx
const LatestPropsContext = React.createContext();

const Parent = () => {
  const [state, setState] = useState();
  const contextValue = { state };

  return (
    <LatestPropsContext.Provider value={contextValue}>
      <Child />
    </LatestPropsContext.Provider>
  );
};

const Child = () => {
  const callbackRef = useRef();
  
  callbackRef.current = () => {
    // Доступ к актуальным пропсам и контексту
    const { state } = useContext(LatestPropsContext);
    console.log(state);
  };

  useEffect(() => {
    const timer = setInterval(() => {
      callbackRef.current();
    }, 1000);
    
    return () => clearInterval(timer);
  }, []); // ❗️Эффект не зависит от изменений функции
};

Когда useMemo возвращает новый объект

При передаче вложенных объектов учитывайте их изменчивость:

jsx
// Компонент получит новый пропс при каждом рендере ({} !== {})
const styles = useMemo(() => ({ color: 'blue' }), []);
// Лучше:
const styles = { color: 'blue' }; 
// Или вынесите из компонента

Ключевые принципы для инженеров

  1. Не оптимизируйте предварительно — добавляйте мемоизацию при появлении проблем
  2. Измеряйте перед оптимизацией — не догадывайтесь о производительности
  3. Компоненты > микрооптимизации — реструктуризация приложения может решить больше проблем, чем useMemo
  4. Стабильность зависимостей — тщательно продумывайте массивы зависимостей

Мемоизация — инструмент для конкретных сценариев, а не серебряная пуля. Чрезмерное использование:

  • Увеличивает сложность кода
  • Расходует память
  • Может вызвать GC-накладки

Глубокую оптимизацию стоит проводить только для доказанных узких мест приложения.