Оптимизация ререндеров – дело тонкое. Неумелое применение инструментов мемоизации часто приводит к обратному эффекту – вместо ускорения приложения мы получаем сложность кода и трудноуловимые баги. Разберем ситуации, когда оптимизация необходима, и как применять useMemo, useCallback и React.memo эффективно и безопасно.
Как React обрабатывает обновления
Ререндер в React вызывается при:
- Изменении состояния компонента (useState, useReducer)
- Изменении полученных пропсов
- Изменении значения контекста (useContext)
- Изменении родительского компонента (это важно!)
React по умолчанию ререндерит весь компонент и всех его потомков при любом изменении. Это просто для разработчика, но ресурсоемко для производительности:
const Parent = () => {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<ChildA /> {/* Рендерит при каждом клике */}
<ChildB complexData={expensiveDataGenerator()} /> {/* Генерирует данные каждое обновление */}
</>
);
};
Здесь ChildA
будет перерисовываться без необходимости, а expensiveDataGenerator()
– вызываться при каждом клике, даже если её результат статичен.
Инструменты оптимизации в действии
React.memo для компонентов
React.memo
предотвращает повторное создание компонента, если его пропсы не изменились:
const Child = ({ items }) => {
// Тяжелые вычисления внутри компонента
return <div>{items.length} элементов</div>;
};
export default React.memo(Child); // Оптимизированная версия
Но будьте осторожны: компонент все равно ререндерится, если меняются любые его пропсы, включая колбэки:
<Child
onAction={() => parentHandler(id)} // Создает новую функцию на каждом рендере
/>
Такой колбэк гарантирует, что React.memo
будет бесполезен.
useCallback для фиксации функций
Решает проблему "плавающих" колбэков. Фиксируем функцию между рендерами:
const Parent = ({ id }) => {
const handleAction = useCallback(() => {
sendToServer(id);
}, [id]); // Функция обновится только при изменении id
return <Child onAction={handleAction} />;
};
Важный нюанс: зависимости в useCallback
должны включать все значения из внешней области видимости. Пропуск зависимостей приводит к использованию устаревших значений.
useMemo для дорогих вычислений
Запоминает результат вычислений между рендерами:
const complexObject = useMemo(() => {
return transformItems(rawItems); // Тяжелая операция
}, [rawItems]); // Выполняется только при изменении исходных данных
Ключевые особенности:
- Идеально для сложных трансформаций данных, математических операций
- Не гарантирует однократное выполнение – вычисления могут происходить многократно
- Не должен содержать сайд-эффекты
Когда "разрулить" оптимизацию
Синтетический пример проблемного кода:
const ProductList = ({ products }) => {
const [selection, setSelection] = useState(null);
const filterProducts = () => {
return products.filter(p => p.price > 100); // Вычисляется при каждом рендере
};
const handleSelect = (product) => {
setSelection(product);
};
return (
<>
<ProductSelector onSelect={handleSelect} />
<ExpensiveProductDisplay items={filterProducts()} />
</>
);
};
Проблемы:
filterProducts()
вызывается на каждый рендерhandleSelect
создается заново при каждом обновленииProductSelector
иExpensiveProductDisplay
ререндерятся при любых изменениях
Оптимизированная версия:
const ProductList = ({ products }) => {
const [selection, setSelection] = useState(null);
const filteredProducts = useMemo(() => {
return products.filter(p => p.price > 100);
}, [products]); // Только при изменении products
const handleSelect = useCallback((product) => {
setSelection(product);
}, []); // setSelection стабилен по умолчанию
return (
<>
<ProductSelector onSelect={handleSelect} />
<React.memo(ExpensiveProductDisplay) items={filteredProducts} />
</>
);
};
Когда не нужно оптимизировать
Не применяйте мемоизацию автоматически!
- Компоненты, рендерящие меньше 500 элементов SVG
- Листовые компоненты без сложных вычислений
- Компоненты, которые всегда обновляются одновременно с родителем
- Кейсы, где bottleneck не в рендеринге (например, медленный API)
Проверьте: если в DevTools Profiler рендер занимает менее 3мс – оптимизация не даст заметного эффекта.
Продвинутые практики
-
Декомпозиция состояния Разбивайте состояние на более мелкие части, чтобы обновлять только затронутые компоненты:
jsxconst Main = () => { const [user, setUser] = useState(); const [cart, setCart] = useState(); return ( <> <UserProfile user={user} /> <ShoppingCart cart={cart} /> </> ); };
-
Контекст + useMemo При оптимизации контекста передавайте мемоизированные значения:
jsxconst UserContext = createContext(); const UserProvider = ({ children }) => { const [user, setUser] = useState(); const value = useMemo( () => ({ user, setUser }), [user, setUser] ); return ( <UserContext.Provider value={value}> {children} </UserContext.Provider> ); };
-
Профилирование Всегда проверяйте эффект оптимизации через React DevTools:
- Замеряйте время рендера до оптимизации
- Повторяйте замеры после изменений
- Используйте "Highlight updates" для визуализации ререндеров
Заключение: баланс вместо фанатизма
Оптимизация рендеров – оружие обоюдоостро. Применяйте её там, где действительно существуют измеримые проблемы с производительностью, подтверждённые профилированием.
Основные правила разумной мемоизации:
- Начинайте оптимизацию только при реальных проблемах
- Используйте
React.memo
для тяжелых компонентов - Фиксируйте колбэки
useCallback
при передаче в оптимизированные компоненты - Кэшируйте сложные вычисления через
useMemo
- Всегда отмечайте зависимости корректно
- Проверяйте результат инструментами разработчика
Избыточная оптимизация усложняет код и может замедлить приложение за счет накладных расходов на сравнение зависимостей. Достигайте баланса – оптимальная производительность без потери читаемости кода.