Почему компонент лишний раз рендерится
«Я обернул компонент в 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 у ребёнка — тоже (ребёнок и так перерендерится вместе с родителем). Работают они только в паре.
Три причины ререндера — и всё
Компонент рендерится заново ровно тогда, когда:
- изменилось его состояние (
useState,useReducer); - перерендерился родитель — по умолчанию React обходит всё поддерево вниз, не сравнивая пропсы;
- изменилось значение контекста, который компонент читает через
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, разделить контекст на данные и действия |
| Компонент рендерится дважды подряд в dev | StrictMode намеренно вызывает рендер дважды | Ничего: это ищет побочные эффекты, в проде такого нет |
Как это диагностировать
Не гадайте — измеряйте. Порядок действий, который стоит проговорить на собеседовании:
- 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.