useMemo, useCallback, React.memo и ключи в списках

Вопрос-фильтр между junior и middle: не «что делает useMemo», а «когда он реально нужен и что будет без него».

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

Вопрос

«Чем useMemo отличается от useCallback? Зачем нужен React.memo и почему он часто не помогает? Почему нельзя использовать индекс массива как key?»

Что на самом деле проверяет интервьюер

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

Развёрнутый ответ

Три инструмента и одна идея

  • useMemo(fn, deps) — запоминает результат вычисления.
  • useCallback(fn, deps) — запоминает саму функцию. По сути это useMemo(() => fn, deps).
  • React.memo(Component) — пропускает повторный рендер компонента, если props поверхностно не изменились.

Общий знаменатель — сравнение ссылок. Посмотрим на нём:

const makeHandler = () => () => console.log('press');

const h1 = makeHandler();
const h2 = makeHandler();
console.log('две одинаковые функции равны?', h1 === h2);

const stable = makeHandler();
console.log('та же самая ссылка равна себе?', stable === stable);

const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
console.log('два одинаковых массива равны?', arr1 === arr2);

Вывод:

две одинаковые функции равны? false
та же самая ссылка равна себе? true
два одинаковых массива равны? false

Почему React.memo «не работает»

const Row = React.memo(function Row({ item, onPress }) {
  return (
    <Pressable onPress={() => onPress(item.id)}>
      <Text>{item.title}</Text>
    </Pressable>
  );
});

function List({ items }) {
  // новая функция и новый объект стиля на каждом рендере — memo бесполезен
  return items.map((item) => (
    <Row key={item.id} item={item} onPress={(id) => open(id)} />
  ));
}

Чинится стабилизацией ссылок:

const handlePress = useCallback((id) => open(id), [open]);
// ...
<Row key={item.id} item={item} onPress={handlePress} />

Когда мемоизация оправдана

  • Вычисление действительно дорогое: сортировка и фильтрация тысяч элементов, разбор большого JSON, группировка.
  • Значение уходит в зависимости useEffect или другого хука — нужна стабильная ссылка.
  • Значение передаётся как props в React.memo-компонент, особенно в ячейку FlatList.
  • Значение кладётся в Context (иначе все потребители перерисуются на каждый рендер провайдера).

И когда не оправдана: обычные строки и числа, лёгкие вычисления, компоненты, которые и так рендерятся редко. У мемоизации есть цена — память под кэш и сравнение зависимостей на каждом рендере.

Ключи в списках

key — это идентичность элемента для алгоритма согласования. Индекс массива означает «элемент, стоящий на позиции N», а это совсем другое утверждение. Смоделируем вставку в начало:

const before = ['Молоко', 'Хлеб', 'Сыр'];
const after = ['Яблоки', 'Молоко', 'Хлеб', 'Сыр'];

const byIndex = (list) => list.map((t, i) => i + ':' + t).join('  ');

console.log('было: ', byIndex(before));
console.log('стало:', byIndex(after));

Вывод:

было:  0:Молоко  1:Хлеб  2:Сыр
стало: 0:Яблоки  1:Молоко  2:Хлеб  3:Сыр

С точки зрения React под ключом 0 «остался тот же элемент», просто у него изменился текст. Компонент не пересоздаётся — а значит, его внутреннее состояние (введённый текст, положение чекбокса, запущенная анимация) остаётся у чужой строки. Отсюда классический баг: отметили галочку, добавили элемент сверху — галочка уехала.

// плохо — если список сортируется, фильтруется или в него вставляют элементы
<FlatList data={items} keyExtractor={(item, index) => String(index)} />

// хорошо — стабильный идентификатор из данных
<FlatList data={items} keyExtractor={(item) => item.id} />

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

Типичные ошибки кандидатов

  • Считают, что useCallback сам по себе ускоряет приложение. Без React.memo на принимающей стороне он обычно не даёт ничего.
  • Оборачивают в useMemo примитивы — расходов больше, чем пользы.
  • Объясняют ключи фразой «чтобы React не ругался в консоли», не понимая связи с состоянием компонента.
  • Не упоминают Context, хотя нестабильное значение в провайдере — одна из самых частых причин лишних рендеров в RN-приложениях.

Как ответить кратко

«useMemo запоминает результат, useCallback — функцию, React.memo отменяет рендер при неизменных props. Все три работают через поверхностное сравнение ссылок, поэтому React.memo бесполезен, если в props приходит новая стрелочная функция или новый объект стиля — их надо стабилизировать. Мемоизация оправдана при дорогих вычислениях и там, где нужна стабильная ссылка: зависимости хуков, props мемоизированных ячеек списка, значение Context. Ключ — это идентичность элемента; индекс означает «позицию», поэтому при вставке или сортировке состояние компонентов съезжает к чужим элементам. Нужен стабильный id из данных.»

Проверьте себя
1. Почему React.memo часто «не работает», если компоненту передают обработчик onPress?
AReact.memo сравнивает props глубоко и всегда находит различия
BСтрелочная функция создаётся заново на каждом рендере, поэтому ссылка на props меняется
CReact.memo не поддерживает функции в props
DОбработчики всегда исключаются из сравнения props
2. Когда useMemo действительно оправдан?
AВсегда: любая мемоизация уменьшает количество работы
BКогда вычисление реально дорогое или когда результат используется как зависимость либо как props мемоизированного компонента
CТолько для строк и чисел
DТолько внутри useEffect
3. Что произойдёт с полем ввода в списке, если использовать индекс массива в качестве key и вставить новый элемент в начало?
AНичего, React корректно перенесёт состояние по содержимому элементов
BСостояние поля останется привязанным к позиции, и введённый текст «переедет» к другому элементу
CReact пересоздаст все элементы списка с нуля
DПриложение упадёт с ошибкой дублирования ключей