Почему компонент лишний раз рендерится

«Я обернул компонент в memo, а он всё равно рендерится на каждый чих. Почему?» — вопрос, на котором сыплется большинство кандидатов.

Ререндер — это повторный вызов функции компонента. Он не означает, что React что-то поменял в DOM.

Вопрос: почему Table рендерится на каждое нажатие клавиши?

const Table = memo(function Table({ rows, columns, onRowClick }) {
  console.log('Table рендерится');
  return <div>{rows.length} строк</div>;
});

function Page({ rows }) {
  const [query, setQuery] = useState('');

  const columns = [{ key: 'name', title: 'Имя' }];
  const handleRowClick = (id) => console.log(id);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Table rows={rows} columns={columns} onRowClick={handleRowClick} />
    </>
  );
}

Ответ: memo сравнивает пропсы поверхностно (shallow), то есть по ссылке для каждого пропа. Массив columns и функция handleRowClick создаются заново при каждом рендере Page — это новые объекты в памяти. Сравнение Object.is(старый columns, новый columns) даёт false, и memo честно пропускает ререндер дальше. Каждая буква в поиске — новый рендер таблицы.

Проверим механику на чистом JS:

const shallowEqual = (a, b) => {
  const ka = Object.keys(a);
  const kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  return ka.every((k) => Object.is(a[k], b[k]));
};

// так пропсы собираются при каждом рендере родителя
const propsFromRender = () => ({ title: 'Отчёт', style: { color: 'red' }, onSave: () => {} });
console.log('memo пропустит ререндер?', shallowEqual(propsFromRender(), propsFromRender()));

// а так — со стабильными ссылками (useMemo / useCallback)
const style = { color: 'red' };
const onSave = () => {};
const stableProps = () => ({ title: 'Отчёт', style, onSave });
console.log('а теперь пропустит?', shallowEqual(stableProps(), stableProps()));

Результат:

memo пропустит ререндер? false
а теперь пропустит? true

Два одинаковых на вид объекта { color: 'red' } — это два разных объекта. Именно поэтому «одинаковые» пропсы не спасают: React сравнивает не содержимое, а ссылки.

Починка

function Page({ rows }) {
  const [query, setQuery] = useState('');

  const columns = useMemo(() => [{ key: 'name', title: 'Имя' }], []);
  const handleRowClick = useCallback((id) => console.log(id), []);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Table rows={rows} columns={columns} onRowClick={handleRowClick} />
    </>
  );
}

Важно понимать связку: memo без стабильных ссылок бесполезен, а useCallback без memo у ребёнка — тоже (ребёнок и так перерендерится вместе с родителем). Работают они только в паре.

Три причины ререндера — и всё

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

  1. изменилось его состояние (useState, useReducer);
  2. перерендерился родитель — по умолчанию React обходит всё поддерево вниз, не сравнивая пропсы;
  3. изменилось значение контекста, который компонент читает через useContext.

Пункт 2 обычно и удивляет: «но я же не менял его пропсы!» React и не проверяет пропсы, пока вы не попросили — то есть пока компонент не обёрнут в memo. По умолчанию перерисовать поддерево дешевле, чем сравнивать пропсы каждого узла.

Есть и приятное исключение. Если ребёнок пришёл в компонент как проп (например, через children), его элемент создаётся в другом месте — и при рендере обёртки не пересоздаётся. Это композиция без всякого memo:

// Counter перерендеривается, а SlowTree — нет:
// его элемент создан в App и приходит в Counter готовым пропом
function App() {
  return (
    <Counter>
      <SlowTree />
    </Counter>
  );
}

function Counter({ children }) {
  const [n, setN] = useState(0);
  return (
    <div>
      <button onClick={() => setN(n + 1)}>{n}</button>
      {children}
    </div>
  );
}

Ререндер ≠ обновление DOM

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

Поэтому «лишний ререндер» сам по себе не преступление. Проблема начинается, когда рендер дорогой: тяжёлые вычисления в теле компонента, тысячи узлов в поддереве, сортировка большого массива на каждый вызов. Оптимизировать нужно не факт ререндера, а его стоимость — и только после замера.

СимптомОбычная причинаЛечение
Ребёнок в memo всё равно рендеритсяНовая ссылка на объект/массив/функцию в пропсахuseMemo / useCallback, вынести константу за компонент
Полстраницы рендерится на каждый символ в полеСостояние поднято слишком высокоОпустить useState в тот компонент, который им пользуется
Все потребители контекста обновляются разомНовый объект в value={{ user, setUser }} на каждом рендереuseMemo для value, разделить контекст на данные и действия
Компонент рендерится дважды подряд в devStrictMode намеренно вызывает рендер дваждыНичего: это ищет побочные эффекты, в проде такого нет

Как это диагностировать

Не гадайте — измеряйте. Порядок действий, который стоит проговорить на собеседовании:

  • React DevTools → Profiler. Записать взаимодействие, посмотреть flame-график: кто рендерился, сколько миллисекунд занял. В настройках включить «Record why each component rendered» — DevTools прямо напишет причину: «Props changed: onRowClick» или «Hook 1 changed».
  • Highlight updates when components render в настройках DevTools — перерисовываемые компоненты подсвечиваются рамкой прямо на странице. Мигает вся страница на нажатие клавиши — вот и ответ.
  • Вкладка Performance в браузере. Показывает, сколько времени ушло на JS, а сколько — на пересчёт стилей и раскладку. Если DOM почти не менялся, а тормозит — виноват дорогой рендер, а не React.
  • console.log в теле компонента — грубо, но за десять секунд отвечает на вопрос «а он вообще рендерится?».

Частые ошибки на собеседовании

  • «Каждый ререндер перерисовывает DOM». Нет. Если дерево элементов совпало, React не сделает ни одной операции с DOM.
  • Обернуть всё подряд в memo/useMemo. Сравнение пропсов и мемоизация тоже стоят времени и памяти. Оптимизация без профайлера — это карго-культ, и интервьюер это спросит.
  • Забыть про memo у ребёнка и удивляться, что useCallback «не работает». Без memo ребёнок перерендерится в любом случае.
  • Не заметить контекст. Новый объект в value провайдера обновляет вообще всех потребителей, никакой memo на потребителе тут не спасёт.
  • Считать двойной рендер в dev багом. Это StrictMode, и он умышленно ищет непредсказуемые побочные эффекты.

Итоги-шпаргалка

  • Причин ререндера три: своё состояние, рендер родителя, изменение контекста.
  • По умолчанию рендер родителя тянет за собой всё поддерево — пропсы не сравниваются, пока нет memo.
  • memo сравнивает пропсы поверхностно, по ссылке: {} !== {}, новая стрелочная функция каждый раз новая.
  • memo + useCallback/useMemo работают только в связке.
  • Ребёнок, переданный через children, не пересоздаётся при рендере обёртки — бесплатная оптимизация.
  • Ререндер ≠ обновление DOM. Опасен не ререндер, а дорогой ререндер.
  • Диагностика: Profiler с «why each component rendered», подсветка обновлений, вкладка Performance.
Проверьте себя
1. Дочерний компонент обёрнут в memo, но всё равно рендерится при каждом рендере родителя. Какая причина наиболее вероятна?
Amemo работает только с классовыми компонентами
BРодитель передаёт проп-объект или стрелочную функцию, которые пересоздаются на каждом рендере, поэтому поверхностное сравнение даёт false
Cmemo отключается в режиме разработки
DУ дочернего компонента изменился key
2. Компонент перерендерился, но вернул точно такое же дерево элементов. Что сделает React с DOM?
AПерезапишет соответствующее поддерево DOM заново
BОбновит только атрибуты, но не текст
CНе сделает с DOM ничего: изменений нет, коммитить нечего
DРазмонтирует и смонтирует компонент заново