На тему мемоизации написано довольно много статей, и все они хорошо раскрывают суть. Но мне часто не хватало шпаргалки, которую можно отправить на вопрос «А как мемоизировать?». В статье речь пойдет исключительно о функциональных компонентах.
Жизненный цикл компонента
Функциональный компонент — это обычная JavaScript-функция, и часто при обсуждении, когда говорят «компонент рендерится», имеют в виду само выполнение этой функции.
При создании компонента, когда функция выполняется впервые, говорят, что компонент «рендерится». А когда в компоненте что-то изменилось, то говорят, что компонент «перерендеривается».
С изначальным рендерингом все хорошо, это необходимый шаг, чтобы компонент создался. Но вот перерендеры могут быть лишними, и с ними можно (а иногда нужно) бороться.
Когда компонент будет перерендерен?
Есть всего несколько сценариев, когда компонент будет перерендерен:
Изменилось значение в useState, useReducer.
Изменился контекст, а наш компонент потребляет этот контекст через useContext.
Перерендерился родитель.
Как бороться?
Так как у нас есть несколько причин перерендера компонента, рассмотрим, как бороться с каждым из них.
Как бороться с useState/useReducer?
Если компонент перерендеривается из-за изменений значения в useState (или useReducer), то скорее всего, с таким перерендером не нужно бороться, так как новое значение нужно показать пользователю.
Пример, когда значение нам нужно сразу показать:
const CounterComponent = () => {
const [count, setCount] = useState(0);
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
Но если мы не используем значение, а только где-то храним, то от лишних перерендеров можно избавиться с помощью useRef:
❌ Плохой пример, в котором будут лишние перерендеры:
const CounterComponent = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => alert(count)}> Show count </button>
</div>
);
};
✅ Хороший пример, в котором лишних перерендеров не будет:
const CounterComponent = () => {
const countRef = useRef(0);
return (
<div>
<button onClick={() => countRef.current++}>Increment</button>
<button onClick={() => alert(countRef.current)}> Show count </button>
</div>
);
};
Как бороться с useContext?
Когда компонент использует useContext, могут быть такие-же проблемы перерендера, как и в случае useState/useReducer.
Один из способов борьбы - использовать useRef. Но часто, проблема может быть в том, что например, контекст - это большой объект, и в нем много данных:
const BigContext = React.createContext({
field1: 1,
field2: 2,
field3: 3,
field4: 4,
field5: 5,
});
const Parent = () => {
const [state, setState] = useState({
field1: 1,
field2: 2,
field3: 3,
field4: 4,
field5: 5,
});
return (
<BigContext.Provider value={state}>
<Child />
</BigContext.Provider>
);
};
Тогда, при обновлении одного поля, изменится весь объект, и будут перерендерены все компоненты, которые потребляют этот контекст.
Как бороться с перерендером из-за родителя?
В React, если родитель перерендерился, то будет перерендерен и дочерний компонент.
Например:
const ParentComponent = () => {
const [count, setCount] = useState(0);
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<ChildComponent />
</div>
);
};
const ChildComponent = () => {
return <div />;
};
Как только мы нажмем на кнопку, то перерендерится ParentComponent, а с ним и ChildComponent. Но это же опасно, ведь если перерендерится корневой компонент, например App, то он перерендерит все дерево компонентов.
В борьбе с таким видом перерендера, нам поможет memo:
const ParentComponent = () => {
const [count, setCount] = useState(0);
return (
<div>
<div>{count}</div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<ChildComponent />
</div>
);
};
const ChildComponent = memo(() => {
return <div />;
});
А работает memo следующим образом:
Как только родитель перерендеривается, если его дочерний компонент завернут в memo, то вместо перерендера, сначала будут проверены пропсы. Если пропсы изменились, то компонент будет перерендерен. А если нет, то не будет. Можно дополнительно настроить memo, но разбирать в этой статье мы не будем.
Мы разобрали способы борьбы со всеми видами перерендеров. Но про memo хочется добавить несколько примеров.
Рассмотрим пример:
const ParentComponent = () => {
const [count, setCount] = useState(0);
const onButtonClick = () => {
setCount(count + 1);
};
return (
<div>
<div>{count}</div>
<ButtonComponent onClick={onButtonClick} />
</div>
);
};
const ButtonComponent = memo(({ onClick }) => {
return <button onClick={onClick}>Click me!</button>;
});
Как только будет нажата кнопка, счетчик изменится, и компонент ParentComponent будет перерендерен. А будет ли перерендерен ButtonComponent? Так как он завернут в memo, сначала будет проверено, изменились ли пропсы. Так как у нас onButtonClick каждый раз создается заново, это означает, что пропсы при сравнении будут разные.
Для этого можно использовать useCallback.
const onButtonClick = useCallback(() => {
setCount(count + 1);
}, []);
Но так, count всегда будет равен нулю, потому что в этом случае, у нас useCallback запомнит первую переданную функцию, и у этой функции лексическое окружение тоже запомнится (иначе говоря замкнется). И тогда count в этой функции будет всегда равен нулю.
Это поведение можно исправить так:
const onButtonClick = useCallback(() => {
setCount((c) => c + 1);
}, []);
Но что, если нам нужно делать в функции другие действия, связанные с переменной, например:
const onButtonClick = useCallback(() => {
console.log(count);
}, []);
Тогда снова сталкиваемся с проблемой, когда count остается равным нулю.
Мы можем избавиться от проблемы, указав count в зависимостях:
const onButtonClick = useCallback(() => {
console.log(count);
}, [count]);
Но тогда, при изменении count-а в onButtonClick будет новая функция, и тогда теряется вся полезность от useCallback.
Решить это можно так:
const countRef = React.useRef(count);
countRef.current = count;
const onButtonClick = useCallback(() => {
console.log(countRef.current);
}, []);
Мы создаем ref, и на каждом перерендере в него сохраняем актуальное значение count-а. И теперь, onButtonClick всегда будет иметь доступ к актуальному значению, и мы получаем преимущество мемоизации, onButtonClick - всегда одна и та же функция.
Но выглядит это костыльно, и если переменных много, придется создавать много ref-ов.
А что если в ref писать не значение, а саму функцию, с ее лексическим окружением?
Кастомный хук useCallbackRefed
Можно использовать такой кастомный хук, который дает нам прелести мемоизации функции, и одновременно у нас всегда актуальные значения:
const useCallbackRefed = (callback) => {
const ref = useRef(callback);
ref.current = callback;
return useCallback((...args) => {
return ref.current(...args);
}, []);
};
Пример использования хука:
const onButtonClick = useCallbackRefed(() => {
console.log(count);
});
А еще, коллбэк, завернутый в этот хук можно безопасно использовать в useEffect, ведь функция всегда одна и та же, а значение count всегда будет в ней актуальное:
const listener = useCallbackRefed(() => {
console.log(count); // Всегда актуально!
});
useEffect(() => {
if (!ref.current) {
return;
}
ref.current.addEventListener("mouseover", listener);
return () => {
ref.current.removeEventListener("mouseover", listener);
};
}, []);
Выводы
Полезно знать о методах борьбы с перерендерами, но если вы делаете маленький проект или проект, который исключительно будет запускаться на мощных устройствах, то использование всех методов борьбы может стать преждевременной оптимизацией.
А как мы знаем, преждевременная оптимизация — корень всех зол.
Комментарии (8)
LyuMih
20.02.2025 07:03Перерендеры переоценены, пока они не начнут вызывать явное замедление проекта.
Силы и время, потраченное на бездумное написание useRef/useCallback/memo в каждом компоненте чаще всего только вредят.
Нагрузка на чтение кода с useRef/memo/useCallback возрастает в разы, а связанные с этим баги (забыли пропс в зависимости добавить и т.п.) трудно отловить сразу.
Пишите простой код, пока ваше приложение не начнёт явно замедляться. Потом лучше сделать рефакторинг и пересобрать композицию компонентов, чтобы созависимых компонентов было меньше.
P.S. React 19 движется в этом направлении - React Compiler
Riim
20.02.2025 07:03Удивителен путь которым пошёл современный фронтенд. Как будто не хватало всей ущербности VDOM с его dirty checking-ом, так нужно было ещё придумать функциональные компоненты с их необходимостью обмазываться мемоизацией на каждый чих. Теперь бесконечно боремся и превозмогаем, а если надоело, тоже не проблема, вон выше у человека не тормозит, значит и у пользователя не тормозит и батарейка на телефоне не жрётся, пусть перерендеривается сколько хочет, это теперь норма. Ощущение, как будто смотрю репортаж из психушки.
fransua
20.02.2025 07:03Все эти решения полумеры, в большом приложении будет много изменений, которые иногда должны запускать рендер, иногда нет, в зависимости от фазы луны, курса доллара и десятка настроек. И правильно сказано выше, что когнитивная нагрузка растет очень быстро.
Решения для этого существуют давно - стейт менеджеры: mobx, jotai, redux в конце концов. Можно долго холиварить, какой из них плох, но я не встречал никого, кто скажет, что лучше useState+useContext чем самый ужасный state manager.
shuga2704
20.02.2025 07:03Небольшое уточнение по поводу причин ререндера: их на самом деле всего одна - useReducer и его упрощенная производная useState. Контекст сам по себе не имеет никакого отношения к ререндерам (мы можете прокинуть в него обычную let-переменную, объявленную на уровне модуля и изменять ее, и вы увидите, что ререндера не произойдет, хотя контекст присутствует).
И сам ререндер родителя это лишь следствие того, что я написал выше. Поэтому можно говорить, что он влияет лишь косвенно.
Отсюда вывод: одной единственной причиной ререндера является изменение состояния.BruTO8000 Автор
20.02.2025 07:03Если так мыслить, то мы можем просто ответить — перерендер, следствие изменения состояния. Да, это так и есть, но какую пользу дает такая интерпретация? А вот думать о причинах перерендерах так, как я представил в статье, очень полезно. Так сразу можно понять, какую стратегию оптимизации выбрать.
MisterAnx
Как человек который вообще не знает React, статья даже была понятно, и теперь я задумался а если перерендер дочерних элементов на моей фреймворке. Так же на собесе могу кратко ответить зачем мемоизация, вдруг когда то перейду на react. Спасибо!