Теория: Зачем нужны useCallback и useMemo?
Вот мы и дошли до одного из ключевых инструментов оптимизации производительности в React — хуков useCallback и useMemo. Если бы React-приложение было автомобилем, то эти хуки можно сравнить с режимами "ЭКО": они помогают экономить ресурсы и избегать лишних рендеров. Сегодня мы поговорим о том, как и зачем их оптимизировать.
Прежде чем углубляться в код, давайте разберёмся, почему вообще стоит их использовать:
- useCallback: нужен для мемоизации функций, чтобы они не создавались заново при каждом ререндере компонента.
- useMemo: нужен для мемоизации вычислений, чтобы тяжелые операции (например, фильтрация или сортировка данных) не выполнялись на каждом рендере.
Проблема: Ненужное создание функций
В React при каждом ререндере компонента пересоздаются все функции, объявленные внутри него. Это может быть не проблема в маленьких приложениях, но, если эти функции передаются вниз по дереву компонентов, это приведёт к лишним рендерам дочерних компонентов. Вот пример:
import React, { useState } from 'react';
const Button = React.memo(({ onClick }: { onClick: () => void }) => {
console.log('Button rendered');
return <button onClick={onClick}>Click me</button>;
});
export const App = () => {
const [counter, setCounter] = useState(0);
const increment = () => {
setCounter(counter + 1);
};
console.log('App rendered');
return (
<div>
<h1>Counter: {counter}</h1>
<Button onClick={increment} />
</div>
);
};
Попробовав это приложение, вы удивитесь: несмотря на использование React.memo, кнопка Button будет рендериться заново при каждом изменении счётчика. Почему? Потому что increment, передаваемый как пропс, каждый раз пересоздаётся, и React.memo считает его другим.
Как это исправить: useCallback
Здесь useCallback — ваш спаситель. Он позволяет один раз создать функцию и "вспоминать" её, если зависимости не изменились. Давайте исправим наш код:
import React, { useState, useCallback } from 'react';
const Button = React.memo(({ onClick }: { onClick: () => void }) => {
console.log('Button rendered');
return <button onClick={onClick}>Click me</button>;
});
export const App = () => {
const [counter, setCounter] = useState(0);
const increment = useCallback(() => {
setCounter(counter + 1);
}, [counter]); // Зависи от `counter`
console.log('App rendered');
return (
<div>
<h1>Counter: {counter}</h1>
<Button onClick={increment} />
</div>
);
};
Теперь, если вы попробуете это снова, кнопка больше не будет рендериться, если её пропс onClick не изменился. Почему? Потому что теперь функция increment сохраняется в памяти и не пересоздаётся при каждом рендере.
Как использовать useMemo: оптимизация вычислений
А теперь обратимся к ситуации, когда у вас есть сложные вычисления, которые не должны выполняться при каждом рендере. Например, пусть у нас есть большая таблица данных, которую необходимо фильтровать:
import React, { useState } from 'react';
const Table = ({ data }: { data: string[] }) => {
console.log('Table rendered');
return (
<ul>
{data.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
);
};
export const App = () => {
const [query, setQuery] = useState('');
const [items] = useState([
'Banana',
'Apple',
'Orange',
'Pineapple',
'Grapes',
'Mango',
]);
const filteredItems = items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
console.log('App rendered');
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
/>
<Table data={filteredItems} />
</div>
);
};
Каждый раз, когда пользователь набирает текст, filteredItems создаётся заново, и это приводит к повторному выполнению фильтрации. Это может быть серьёзной проблемой, если данных много. Вот где помогает useMemo:
import React, { useState, useMemo } from 'react';
const Table = ({ data }: { data: string[] }) => {
console.log('Table rendered');
return (
<ul>
{data.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
);
};
export const App = () => {
const [query, setQuery] = useState('');
const [items] = useState([
'Banana',
'Apple',
'Orange',
'Pineapple',
'Grapes',
'Mango',
]);
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
}, [query, items]); // Выполняется только если изменился `query` или `items`
console.log('App rendered');
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
/>
<Table data={filteredItems} />
</div>
);
};
Теперь фильтрация происходит только тогда, когда действительно необходимо, а не при каждом ререндере.
Типизация useCallback и useMemo
Как и для всего в TypeScript, для useCallback и useMemo важна правильная типизация.
useCallback:
Функция, переданная в useCallback, автоматом подхватывает типы из переданной ей функции. Например:
const increment = useCallback(() => {
setCounter((prev) => prev + 1);
}, []);
Если нужна явная типизация:
const increment: () => void = useCallback(() => {
setCounter((prev) => prev + 1);
}, []);
useMemo:
Типизация результата useMemo:
const filteredItems: string[] = useMemo(() => {
return items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
}, [query, items]);
Главные особенности и важные советы
- Не используйте useCallback и useMemo ради использования. Если вы не сталкиваетесь с лишними рендерами или тяжёлыми вычислениями, скорее всего, они вам не нужны.
- Будьте аккуратны с зависимостями. Если вы забыли указать что-то в массиве зависимостей, это может привести к багам. TypeScript поможет отловить большинство таких ошибок.
- Инструмент профилирования React DevTools. Используйте его, чтобы понять, где приложение действительно "тормозит", прежде чем оптимизировать.
Теперь, когда вы вооружены знаниями про useCallback и useMemo, начните их применять в своём реальном проекте. Эти инструменты гарантированно повысят производительность вашего приложения, если использовать их с умом!
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ