Як працює useCallback і навіщо він потрібен
useCallback - це React хук, який мемоізує (зберігає в пам'яті) функцію і повертає той самий референс між рендерами, доки залежності не зміняться.
Теорія
TL;DR
- Кожен рендер створює нові екземпляри функцій; дві однакові стрілочні функції не рівні за
=== useCallbackкешує функцію і повертає кешований варіант, якщо залежності не змінились- Головний сценарій: колбеки, передані до
React.memo-компонентів, щоб пропустити зайві рендери - Без мемоізованого дочірнього компонента
useCallbackдодає оверхед без жодної вигоди - Правило: спочатку React DevTools Profiler, потім додавай
useCallbackтільки там, де є реальний ефект
Швидкий приклад
import { useState, useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// Без useCallback: нова функція при кожному рендері -> Button рендериться завжди
// const handleClick = () => setCount(c => c + 1);
// З useCallback: той самий референс, якщо deps не змінились -> Button пропускає рендер
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // порожні deps = ніколи не перестворюється
return (
<>
<Button onClick={handleClick} />
<button onClick={() => setOther(o => o + 1)}>Змінити other</button>
</>
);
}
const Button = React.memo(({ onClick }) => {
console.log('Button render'); // пропускає, коли референс handleClick стабільний
return <button onClick={onClick}>Натисни</button>;
});Коли other змінюється, Parent рендериться заново, але Button пропускає рендер, бо handleClick тримає той самий референс.
Чому важливі референси функцій
Кожен рендер створює нові об'єкти функцій. Дві стрілочні функції з однаковим тілом - це різні референси:
const a = () => {};
const b = () => {};
console.log(a === b); // falseReact.memo порівнює пропси поверхнево. Новий референс функції запускає рендер дочірнього компонента, навіть якщо логіка всередині не змінилась. useCallback розриває цей ланцюг, повертаючи кешований екземпляр, якщо deps збіглись.
Коли використовувати
Використовуй, якщо:
- Передаєш колбек до
React.memo-компонента (основний сценарій) - Функція є залежністю в
useEffectабоuseMemo- новий референс при кожному рендері викликає нескінченні цикли - Обробники для елементів у великих мемоізованих списках
Пропускай, якщо:
- Немає мемоізованого дочірнього компонента, що отримує функцію
- Залежності змінюються при кожному рендері (хук все одно перестворює)
- Це внутрішня утиліта, яка не передається як проп
Як React зберігає колбеки всередині
React тримає мемоізовані колбеки в зв'язаному списку memoizedState вузла fiber. При кожному рендері він порівнює масив deps через Object.is для кожного елемента (поверхнє порівняння). Якщо всі елементи збіглись, React повертає кешоване замикання (closure) без нової алокації. Якщо хоча б один dep змінився, кешований запис замінюється.
Бачив команди, які додають useCallback скрізь як рефлекс, очікуючи загального прискорення. Це не так. Вигода приходить саме від рендерів дочірніх компонентів, які вдається пропустити, а не від уникнення алокації функцій самих по собі.
useCallback проти useMemo
| useCallback | useMemo | |
|---|---|---|
| Повертає | Функцію | Обчислене значення |
| Типовий сценарій | Стабільний колбек для дочірнього компонента або deps ефекту | Результат дорогого обчислення |
| Приклад | useCallback(() => api.save(id), [id]) | useMemo(() => filterList(todos), [todos]) |
Внутрішньо useCallback(fn, deps) еквівалентний useMemo(() => fn, deps). Потрібен стабільний референс для передачі - useCallback. Потрібно кешувати результат обчислення - useMemo.
Типові помилки
1. Порожні deps, коли колбек захоплює стан або пропси
// Неправильно: user.id завжди матиме значення з моменту монтування
const save = useCallback(() => api.save(user.id), []);
// Правильно
const save = useCallback(() => api.save(user.id), [user.id]);З [] замикання (closure) захоплює початковий user.id і ігнорує всі оновлення. Класичний баг застарілого замикання (stale closure).
2. Обгортка функцій, які не передаються як пропси
// Неправильно: жоден мемоізований компонент це не отримує, нульова вигода
const formatDate = useCallback((d) => d.toISOString(), []);
// Правильно: звичайна функція
const formatDate = (d) => d.toISOString();Платиш за порівняння deps при кожному рендері і нічого не отримуєш.
3. Об'єкт або масив у масиві залежностей
// Неправильно: config - новий об'єкт при кожному рендері -> tick завжди перестворюється
const config = { delay };
const tick = useCallback(() => doSomething(config), [config]);
// Правильно: використовуй примітив напряму
const tick = useCallback(() => doSomething(delay), [delay]);Object.is порівнює референси, а не вміст. Новий об'єктний літерал при кожному рендері завжди не пройде порівняння і обнулить мемоізацію.
4. Вимкнення react-hooks/exhaustive-deps
Це ESLint-правило ловить deps, які ти забув додати. Вимикати його щоб заглушити попередження - патерн, який ревʼюери помічають відразу. І він відправляє в продакшен застарілі замикання.
5. Застаріле замикання в таймерах
// БАГ: захоплює delay=1000 при монтуванні, ніколи не оновлюється
const tick = useCallback(() => {
setCount(c => c + 1);
}, []); // бракує delay в deps
useEffect(() => {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [tick, delay]);Перемикання затримки змінює стан і показує нове значення в UI, але інтервал спрацьовує з початковою швидкістю. Замикання захопило початковий delay. Рішення: додати delay в deps useCallback. Функція tick перестворюватиметься при зміні затримки, і інтервал буде використовувати правильне значення.
Де використовується на практиці
- React TodoMVC: стабільний колбек
onRemove, переданий доReact.memo(Todo) - Redux Toolkit Query: тригер
useLazyQueryобгорнутий для стабільності при кліках на кнопки - Material-UI DataGrid: обробники рядків мемоізовані, щоб не перерендерювати сотні рядків
- Next.js App Router: колбеки серверних дій тримаються стабільними між навігаціями
React Compiler (з'являється в React 19) автоматично мемоізує компоненти і хуки, що може суттєво зменшити потребу в ручному useCallback.
Можливі питання на співбесіді
Q: Яка різниця між useCallback(fn, []) і зберіганням функції в useRef?
A: useCallback перезапускається при зміні deps і враховується правилом exhaustive-deps. useRef ніколи не перезапускається і не має перевірки deps - підходить для одноразових ініціалізацій на зразок ID інтервалу, але не для колбеків, які замикаються над пропсами або станом.
Q: Чи допомагає useCallback, якщо дочірній компонент не обгорнутий у React.memo?
A: Не для пропуску рендерів. Але якщо функція є залежністю useEffect, стабільний референс не дає ефекту спрацьовувати при кожному рендері.
Q: Чому React використовує поверхнє порівняння deps, а не глибоке?
A: Глибоке порівняння великих масивів або вкладених об'єктів коштуватиме дорожче, ніж рендер, який намагається запобігти. Тому deps тримають примітивами або стабільними референсами.
Q: Чи може useCallback погіршити продуктивність?
A: Так. При 1000 мемоізованих елементах без змін deps оверхед мемоізації вимірюваний у React Profiler - близько 12ms за бенчмарками. Якщо deps змінюються часто, хук перестворює функцію все одно, а ти заплатив за алокацію даремно. Спочатку профайлінг, потім оптимізація.
Q (senior): Чому в RTK Query важливо огортати тригер useMutation в useCallback?
A: RTK Query кешує результати за референсом аргументу. Нестабільний тригер створює новий ключ кешу при кожному рендері, що призводить до промахів кешу і ламає оптимістичні оновлення. Стабільний референс тримає влучання в кеш і дає оптимістичному флоу працювати коректно.
Приклади
Мемоізований дочірній компонент пропускає рендер
import { useState, useCallback } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = useCallback((text) => {
setTodos(t => [...t, { id: Date.now(), text, done: false }]);
}, []);
const toggleTodo = useCallback((id) => {
setTodos(t => t.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}, []);
return (
<div>
<AddForm onAdd={addTodo} />
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={toggleTodo} />
))}
</div>
);
}
const TodoItem = React.memo(({ todo, onToggle }) => {
console.log(`Рендер елемента ${todo.id}`);
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
</label>
);
});toggleTodo тримає той самий референс при кожному рендері. Додавання нового todo перерендерює TodoList, але кожен існуючий TodoItem пропускає рендер, бо onToggle не змінився.
Стабілізація залежності useEffect
function UserProfile({ userId }) {
const [profile, setProfile] = useState(null);
// Без useCallback: fetchProfile нова при кожному рендері
// -> useEffect спрацьовує при кожному рендері -> нескінченний цикл
const fetchProfile = useCallback(async () => {
const data = await api.getUser(userId);
setProfile(data);
}, [userId]); // повторний запит тільки при зміні userId
useEffect(() => {
fetchProfile();
}, [fetchProfile]);
return profile ? <div>{profile.name}</div> : <div>Завантаження...</div>;
}Без useCallback новий fetchProfile при кожному рендері змушував би useEffect спрацьовувати безперервно. З ним ефект запускається тільки коли userId реально змінюється.
Застаріле замикання в таймері
function Timer() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(1000);
// БАГ: захоплює delay=1000 при монтуванні, ніколи не оновлюється
const tick = useCallback(() => {
setCount(c => c + 1);
}, []); // бракує delay в deps
useEffect(() => {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [tick, delay]);
return (
<>
<div>Лічильник: {count} | Затримка: {delay}мс</div>
<button onClick={() => setDelay(d => d === 1000 ? 100 : 1000)}>
Змінити швидкість
</button>
</>
);
}Клік на кнопку показує 100мс в UI, але інтервал і далі спрацьовує раз на 1000мс. Замикання захопило початкову затримку. Рішення: додати delay в deps useCallback. Функція tick перестворюватиметься при зміні затримки, і інтервал використовуватиме правильне значення.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.