Мастер-класс по оптимизации ререндеров в React: От теории к производственным решениям

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

Природа ререндеров в React

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

  1. Изменение пропсов
  2. Изменение состояния (через useState, useReducer)
  3. Перерисовка родительского компонента

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

jsx
// Родительский компонент
const Parent = () => {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Increment: {count}</button>
      <Child /> {/* Рендерится при каждом клике! */}
    </div>
  );
};

const Child = () => {
  console.log("Child rendered!"); // Логируется на каждый клик
  return <div>Static Content</div>;
};

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


Инструменты диагностики

1. DevTools Profiler

Записывайте и анализируйте рендеры компонентов. Смотрите на длительность коммитов и подсвеченные компоненты. Если компонент перерисовывается без изменения пропсов — это кандидат на оптимизацию.

2. Почему этот компонент ререндерится?

Установите React Developer Tools и пользуйтесь вкладкой ⚛️ Profiler. При выборе компонента он покажет:

  • Что вызвало ререндер (пропсы, состояние, контекст)
  • Покажет «глубину рендера» компонента

Техники оптимизации: от простого к сложному

1. React.memo для мемоизации

Оберните «тяжёлые» дочерние компоненты в React.memo, чтобы избежать ререндера при неизменных пропсах.

jsx
const ExpensiveComponent = ({ data }) => {
  // Тяжёлые вычисления
  return <div>{data}</div>;
};

export default React.memo(ExpensiveComponent); // Рендер только при изменении data

Важно: React.memo не панацея. Он полезен только при частых ререндерах с одинаковыми пропсами. Для компонентов, постоянно получающих новые пропсы, он бесполезен.

Кастомная проверка пропсов

Когда пропсы — объекты или массивы:

jsx
React.memo(ExpensiveComponent, (prevProps, nextProps) => {
  return prevProps.items.length === nextProps.items.length 
    && prevProps.config.mode === nextProps.config.mode;
});

2. Управление функциями: useCallback

Функции, создаваемые внутри компонента, изменяются при каждом рендере. Это ломает memo.

jsx
const Parent = () => {
  const [count, setCount] = useState(0);
  
  // ⛔ Плохо: новая функция при каждом рендере
  const handleAction = () => { ... };
  
  // ✅ Хорошо: мемоизация колбэка
  const handleAction = useCallback(() => { ... }, []);
  
  return <Child onAction={handleAction} />;
};

const Child = React.memo(({ onAction }) => { ... });

3. useMemo для тяжёлых вычислений

Не пересчитывайте данные на каждом рендере, если зависимости не изменились.

jsx
const heavyComputation = (items) => {
  // Сортировка, фильтрация, сложная логика...
  return processedData;
};

const Component = ({ items }) => {
  // ⛔ Плохо: пересчёт на каждый рендер
  // const data = heavyComputation(items);
  
  // ✅ Хорошо: мемоизация результата
  const data = useMemo(() => heavyComputation(items), [items]);
  
  return <Chart data={data} />;
};

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


Контекст: скрытая угроза

Изменение значения в Context.Provider вызывает ререндер всех компонентов, использующих этот контекст — даже если они используют лишь неизменившуюся часть данных.

Решение: Разделяйте контексты.

jsx
// ⛔ До: один контекст на всё
<AppContext.Provider value={{ user, theme, cart }}>
  <Header />
  <Content />
</AppContext.Provider>

// ✅ После: разделение по смыслу
<UserContext.Provider value={user}>
  <ThemeContext.Provider value={theme}>
    <CartContext.Provider value={cart}>
      <Header />
      <Content />
    </CartContext.Provider>
  </ThemeContext.Provider>
</UserContext.Provider>

Экстремальный случай: Мемоизация значений контекста.

jsx
const CartContextProvider = ({ children }) => {
  const [cart, setCart] = useState([]);
  
  // Мемоизируем объект контекста
  const contextValue = useMemo(() => ({ cart, setCart }), [cart]);
  
  return (
    <CartContext.Provider value={contextValue}>
      {children}
    </CartContext.Provider>
  );
};

Композиция против Prop Drilling

Чем меньше пропсов проходит через компонент — тем ниже риск лишних ререндеров. Иногда достаточно перестроить архитектуру.

До: Передача колбэков через слои

jsx
<Parent>
  <Child>
    <Grandchild onAction={handleAction} /> // Рендерится при обновлении Parent
  </Child>
</Parent>

После: Использование Composition

jsx
const Parent = () => {
  return (
    <Child>
      {/* Grandchild контент передаётся как чистое дерево */}
      <Grandchild /> 
    </Child>
  );
};

const Child = ({ children }) => {
  // children ререндерится ТОЛЬКО если изменились их пропсы
  return <div>{children}</div>; 
};

Когда не оптимизировать

  1. Профиль производительности чист. Не усложняйте код без замеров профилировщика.
  2. Слишком много мемоизации. useMemo и useCallback увеличивают объём памяти. Для лёгких компонентов оверхед может быть хуже пользы.
  3. Компоненты уровня листьев. Ререндер кнопки или текста практически незаметен.

Реальный кейс: Оптимизация таблицы с фильтрами

Ситуация: Таблица из 100+ строк тормозит при вводе в фильтр.

Диагностика:

  • Инпут фильтра живет в родительском компоненте
  • При каждом вводе ререндерятся: родитель → заголовок таблицы → все строки

Решение:

  1. Выносим инпут фильтра в отдельный компонент (он не должен влиять на таблицу).
  2. Таблицу оборачиваем в React.memo.
  3. Каждую строку мемоизируем по ID.
jsx
const TableRow = React.memo(({ row }) => { ... });

const Table = ({ data }) => {
  return data.map(row => 
    <TableRow key={row.id} row={row} />
  );
};

export default React.memo(Table);
  1. Фильтры храним через useDeferredValue (React 18+), чтобы не блокировать интерфейс:
jsx
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter); // "отложенное" значение

useEffect(() => {
  // Фильтрация будет происходить при "простое"
}, [deferredFilter]);

Код-ревью чеклист

Проверяйте код на:

  • Неумемоизированные колбэки, передаваемые в memo-компоненты
  • Вложенные объекты/массивы в пропсах, изменяющиеся при каждом рендере
  • Контексты, объединяющие независимые данные
  • Ререндеры при прокидке children

Что запомнить

  1. Измеряйте перед оптимизацией. Без профайлера вы стреляете наугад.
  2. React.memo, useCallback, useMemo — ваши лучшие инструменты для контроля ререндеров.
  3. Дробите большие контексты.
  4. Композиция компонентов снижает сцепление.
  5. Сложные вычисления делегируем в воркеры или useMemo.

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

Теперь, когда вкладка Performance показывает зелёные цифры — можно и окунуться в чашку кофе.