Перейти к содержимому

12. useCallback

Привет, кодер! Яша снова на связи, и сегодня мы распахнем завесу над одним из самых обсуждаемых хуков в React – useCallback. Если ты уже уверенно жонглируешь стейтом, эффектами и кастомными хуками, то готов к этому путешествию. useCallback — это как личная заморозка для твоих функций, позволяющая им сохранять свою форму между рендерами. Звучит интригующе, правда? Давай разбираться, зачем нам это нужно и как использовать правильно, чтобы не наломать дров.

Представь себе, что у тебя есть родительский компонент, который рендерит дочерний. И родитель постоянно обновляет свой стейт, вызывая ре-рендер. Если ты передаешь функцию как проп в дочерний компонент, то каждый раз при ре-рендере родителя эта функция создается заново. JavaScript считает новую функцию, даже если она идентична по логике, другой функцией. Это называется потерей референциальной идентичности.

flowchart TD
Parent[Parent Component] -->|1. Render| Func[Create handleClick]
Func -->|2. Pass Prop| Child[MemoizedChild with React.memo]
subgraph Without_useCallback [Problem: Identity Loss]
Func1[handleClick v1]
Func2[handleClick v2]
Func1 !== Func2
end
subgraph With_useCallback [Solution: Stability]
MemoFunc[memoizedHandleClick]
MemoFunc === MemoFunc
end
Child -->|3. Check Props| Diff{Identity changed?}
Diff -- Yes --> ReRender[Re-render Child]
Diff -- No --> Skip[Skip re-render]
style Without_useCallback fill:#ffcccc,stroke:#333
style With_useCallback fill:#ccffcc,stroke:#333

Зависимость дочернего компонента от стабильности ссылки на функцию.

А теперь самое интересное: если твой дочерний компонент обернут в React.memo (или является классовым компонентом, реализующим shouldComponentUpdate), он будет сравнивать пропсы, чтобы решить, нужно ли ему ре-рендериться. Но поскольку функция-проп каждый раз новая, React.memo решает: “Ого, проп изменился! Нужно ре-рендериться!”. И вуаля – оптимизация React.memo идет насмарку.

Вот тут на сцену и выходит useCallback. Он “замораживает” твою функцию, сохраняя её референциальную идентичность между ре-рендерами родителя, пока зависимости не изменятся. Это позволяет React.memo честно выполнить свою работу и избежать лишних ре-рендеров дочерних компонентов.

Синтаксис useCallback прост:

import React, { useCallback } from 'react';
const memoizedCallback = useCallback(
() => {
// Твоя функция
},
[dependencies] // Массив зависимостей
);

useCallback возвращает мемоизированную версию твоего коллбэка, которая будет пересоздана только тогда, когда изменится одна из зависимостей в массиве [dependencies].

Давай рассмотрим это на примере.

Пример 1: Без useCallback – проблема лишних ре-рендеров

У нас есть родительский компонент, который рендерит счетчик и кнопку, и дочерний компонент, который просто принимает коллбэк.

import React, { useState, memo } from 'react';
interface ButtonProps {
onClick: () => void;
label: string;
}
// Дочерний компонент, обернутый в memo для оптимизации
const MemoizedButton: React.FC<ButtonProps> = memo(({ onClick, label }) => {
console.log(`[MemoizedButton] Рендер кнопки: ${label}`); // Лог для отслеживания ре-рендеров
return (
<button onClick={onClick}>
{label}
</button>
);
});
// Родительский компонент
const ParentComponentWithoutCallback: React.FC = () => {
const [count, setCount] = useState<number>(0);
const [anotherCount, setAnotherCount] = useState<number>(0);
// Эта функция будет создаваться заново при каждом ре-рендере ParentComponentWithoutCallback
const handleClick = () => {
setCount(prevCount => prevCount + 1);
};
const handleAnotherClick = () => {
setAnotherCount(prevCount => prevCount + 1);
}
console.log('[ParentComponentWithoutCallback] Рендер родителя'); // Лог для отслеживания ре-рендеров
return (
<div>
<h3>Без useCallback</h3>
<p>Счетчик 1: {count}</p>
<p>Счетчик 2: {anotherCount}</p>
{/* Кнопка ре-рендерится каждый раз, хотя её логика не меняется */}
<MemoizedButton onClick={handleClick} label="Увеличить счетчик 1" />
<button onClick={handleAnotherClick}>Увеличить счетчик 2 (сам родитель)</button>
</div>
);
};
// <ParentComponentWithoutCallback />

Если ты запустишь этот код и будешь нажимать на кнопку “Увеличить счетчик 2”, ты увидишь в консоли:

[ParentComponentWithoutCallback] Рендер родителя
[MemoizedButton] Рендер кнопки: Увеличить счетчик 1

Несмотря на memo, MemoizedButton ре-рендерится, потому что функция handleClick создается заново при каждом изменении anotherCount, и React.memo видит, что onClick проп изменился.

Пример 2: С useCallback – предотвращаем лишние ре-рендеры

Теперь применим useCallback к нашей функции handleClick.

import React, { useState, memo, useCallback } from 'react';
// Используем те же типы и MemoizedButton из примера 1
interface ButtonProps {
onClick: () => void;
label: string;
}
const MemoizedButton: React.FC<ButtonProps> = memo(({ onClick, label }) => {
console.log(`[MemoizedButton] Рендер кнопки: ${label}`);
return (
<button onClick={onClick}>
{label}
</button>
);
});
const ParentComponentWithCallback: React.FC = () => {
const [count, setCount] = useState<number>(0);
const [anotherCount, setAnotherCount] = useState<number>(0);
// Функция handleClick теперь мемоизирована
// Она будет пересоздана только если изменятся её зависимости.
// В данном случае, зависимостей нет, т.к. setCount стабильна.
const handleClick = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []); // Пустой массив зависимостей означает, что функция никогда не будет пересоздана
const handleAnotherClick = () => {
setAnotherCount(prevCount => prevCount + 1);
};
console.log('[ParentComponentWithCallback] Рендер родителя');
return (
<div>
<h3>С useCallback</h3>
<p>Счетчик 1: {count}</p>
<p>Счетчик 2: {anotherCount}</p>
<MemoizedButton onClick={handleClick} label="Увеличить счетчик 1" />
<button onClick={handleAnotherClick}>Увеличить счетчик 2 (сам родитель)</button>
</div>
);
};
// <ParentComponentWithCallback />

Теперь, если ты будешь нажимать на кнопку “Увеличить счетчик 2”, ты увидишь в консоли только:

[ParentComponentWithCallback] Рендер родителя

MemoizedButton больше не ре-рендерится, потому что handleClick сохраняет свою референциальную идентичность благодаря useCallback. Магия!

Пример 3: useCallback с зависимостями

Что делать, если функция использует какие-то переменные из области видимости компонента (стейт или пропсы)? Их нужно указать в массиве зависимостей. Если ты их не укажешь, ты получишь проблему “замыкания устаревших значений” (stale closure), когда мемоизированная функция будет работать со старыми значениями стейта/пропсов.

import React, { useState, memo, useCallback } from 'react';
interface DisplayProps {
onNotify: (message: string) => void;
currentValue: number;
}
const MemoizedDisplay: React.FC<DisplayProps> = memo(({ onNotify, currentValue }) => {
console.log(`[MemoizedDisplay] Рендер, значение: ${currentValue}`);
// Вызываем коллбэк при каком-то событии, например, при клике
return (
<div style={{ border: '1px solid gray', padding: '10px', margin: '10px 0' }}>
<p>Текущее отображаемое значение: {currentValue}</p>
<button onClick={() => onNotify(`Значение было ${currentValue}`)}>
Уведомить о текущем значении
</button>
</div>
);
});
const ParentWithDependencies: React.FC = () => {
const [value, setValue] = useState<number>(0);
const [notifications, setNotifications] = useState<string[]>([]);
const handleNotify = useCallback((message: string) => {
// Внимание! Без `value` в зависимостях, `value` внутри этой функции
// всегда будет 0, если мы не будем использовать функциональное обновление стейта.
// Но для формирования сообщения нам нужно актуальное value.
setNotifications(prev => [...prev, message + ` (актуальное в функции: ${value})`]);
}, [value]); // !!! Важно: `value` здесь - зависимость.
console.log('[ParentWithDependencies] Рендер родителя');
return (
<div>
<h3>С useCallback и зависимостями</h3>
<p>Основное значение: {value}</p>
<button onClick={() => setValue(prev => prev + 1)}>
Увеличить основное значение
</button>
<MemoizedDisplay onNotify={handleNotify} currentValue={value} />
<h4>Уведомления:</h4>
<ul>
{notifications.map((note, index) => (
<li key={index}>{note}</li>
))}
</ul>
</div>
);
};
// <ParentWithDependencies />

Если ты нажмешь несколько раз на “Увеличить основное значение”, а затем на “Уведомить о текущем значении”, ты увидишь, что handleNotify корректно “видит” актуальное value. Если бы мы убрали value из массива зависимостей [value], то в уведомлении всегда было бы Значение было 0 (т.к. value при первом рендере было 0, и функция “запомнила” это).

Типичная ошибка: Забыть зависимости

Забытый массив зависимостей или неполный массив — самая частая причина багов со useCallback. Всегда убеждайся, что все переменные из внешней области видимости, которые используются внутри мемоизированной функции, указаны в массиве зависимостей. TypeScript в некоторых случаях может помочь с этим, если настроен на exhaustive-deps правило в ESLint.

Типичная ошибка: Чрезмерное использование useCallback

Не стоит мемоизировать все функции подряд. useCallback сам по себе имеет небольшие накладные расходы (дополнительная память и сравнение зависимостей). Если функция передается в компонент, который не обернут в React.memo, или если компонент сам по себе очень легкий и не производит ресурсоемких операций, то useCallback может принести больше вреда, чем пользы. Используй его там, где есть заметные проблемы с производительностью, или когда работаешь с React.memo / shouldComponentUpdate.

  1. С React.memo: Когда ты передаешь коллбэки в дочерние компоненты, обернутые в React.memo, чтобы избежать их лишних ре-рендеров.
  2. В зависимостях useEffect или useMemo: Когда функция является зависимостью для другого хука, чтобы предотвратить его лишние срабатывания.
  3. При передаче коллбэков в кастомные хуки: Если кастомный хук сам мемоизирует что-то или является дорогостоящим.

Время для самостоятельной работы, Яша-кодер!

Задание 1: Оптимизируй список задач

У тебя есть компонент TaskList, который отображает список задач. Каждая задача имеет кнопку “Выполнено”.

TaskList.tsx
import React, { useState, memo } from 'react';
interface Task {
id: string;
text: string;
completed: boolean;
}
interface TaskItemProps {
task: Task;
onToggleComplete: (id: string) => void;
}
const TaskItem: React.FC<TaskItemProps> = memo(({ task, onToggleComplete }) => {
console.log(`[TaskItem] Рендер задачи: ${task.text}`);
return (
<li style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
{task.text}
<button onClick={() => onToggleComplete(task.id)} style={{ marginLeft: '10px' }}>
{task.completed ? 'Отменить' : 'Выполнено'}
</button>
</li>
);
});
const TaskList: React.FC = () => {
const [tasks, setTasks] = useState<Task[]>([
{ id: '1', text: 'Изучить useCallback', completed: false },
{ id: '2', text: 'Написать код', completed: false },
{ id: '3', text: 'Пойти гулять', completed: false },
]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const handleToggleComplete = (id: string) => {
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
)
);
};
const filteredTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
console.log('[TaskList] Рендер основного списка');
return (
<div>
<h2>Список Задач</h2>
<div>
<button onClick={() => setFilter('all')}>Все</button>
<button onClick={() => setFilter('active')}>Активные</button>
<button onClick={() => setFilter('completed')}>Выполненные</button>
</div>
<ul>
{filteredTasks.map(task => (
<TaskItem key={task.id} task={task} onToggleComplete={handleToggleComplete} />
))}
</ul>
</div>
);
};

Задача: При каждом изменении filter происходит ре-рендер всех TaskItem, хотя их проп onToggleComplete не изменился. Используй useCallback для handleToggleComplete, чтобы предотвратить лишние ре-рендеры TaskItem при изменении filter. Проверь, что TaskItem рендерится только при изменении своих пропсов.

Задание 2: useCallback и useEffect

Создай компонент, который получает данные с API. У него есть кнопка “Обновить данные”. Используй useEffect для запроса данных, а функцию запроса оберни в useCallback. Убедись, что useEffect срабатывает только при изменении функции запроса (если она меняет свои зависимости) или при явном изменении других зависимостей useEffect.

DataFetcher.tsx
import React, { useState, useEffect, useCallback } from 'react';
interface User {
id: number;
name: string;
}
const DataFetcher: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [refreshTrigger, setRefreshTrigger] = useState<number>(0);
// TODO: Оберни эту функцию в useCallback
const fetchUsers = async () => {
console.log('Fetching users...');
setIsLoading(true);
try {
// Имитация API запроса
const response = await new Promise<User[]>(resolve =>
setTimeout(() => {
const newUsers: User[] = [
{ id: 1, name: 'Alice' + Math.random().toFixed(2) },
{ id: 2, name: 'Bob' + Math.random().toFixed(2) },
];
resolve(newUsers);
}, 1000)
);
setUsers(response);
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// TODO: Здесь должна быть мемоизированная версия fetchUsers
fetchUsers();
}, [/* TODO: Добавь правильные зависимости */ refreshTrigger]); // refreshTrigger для ручного обновления
return (
<div>
<h2>Загрузка Данных с `useEffect` и `useCallback`</h2>
<button onClick={() => setRefreshTrigger(prev => prev + 1)} disabled={isLoading}>
{isLoading ? 'Загрузка...' : 'Обновить данные'}
</button>
{users.length > 0 && (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
};

Задача: Оберни fetchUsers в useCallback с пустым массивом зависимостей (т.к. она не использует локальные стейты/пропсы напрямую, кроме setIsLoading и setUsers, которые стабильны). Затем используй мемоизированную версию fetchUsers в useEffect, добавив её в зависимости useEffect. Убедись, что useEffect не срабатывает при каждом ре-рендере компонента, а только при первом монтировании и при изменении refreshTrigger.

Всегда начинай с написания кода без useCallback. Только если профилирование производительности (например, с помощью React DevTools) показывает, что у тебя есть проблемы с излишними ре-рендерами из-за потери референциальной идентичности функций, тогда стоит подумать о внедрении useCallback. Не используй его по умолчанию для всех функций, иначе ты просто добавишь ненужный оверхед и усложнишь код. useCallback – это инструмент для оптимизации, а не обязательная часть каждой функции.

Удачи в кодинге, Яша!


Попробуйте примеры в интерактивном редакторе: