Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як працює useCallback і навіщо він потрібен». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**useCallback** мемоізує функцію і повертає той самий референс між рендерами, якщо залежності не змінились. ```jsx const handleClick = useCallback(() => { doSomething(id); }, [id]); // перестворюється тільки коли змінюється id ``` **Коли використовувати:** передача колбеків до `React.memo`-компонентів або коли функція є залежністю `useEffect`.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**useCallback** - це React хук, який мемоізує (зберігає в пам'яті) функцію і повертає той самий референс між рендерами, доки залежності не зміняться. ## Теорія ### TL;DR - Кожен рендер створює нові екземпляри функцій; дві однакові стрілочні функції не рівні за `===` - `useCallback` кешує функцію і повертає кешований варіант, якщо залежності не змінились - Головний сценарій: колбеки, передані до `React.memo`-компонентів, щоб пропустити зайві рендери - Без мемоізованого дочірнього компонента `useCallback` додає оверхед без жодної вигоди - Правило: спочатку React DevTools Profiler, потім додавай `useCallback` тільки там, де є реальний ефект ### Швидкий приклад ```jsx 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` тримає той самий референс. ### Чому важливі референси функцій Кожен рендер створює нові об'єкти функцій. Дві стрілочні функції з однаковим тілом - це різні референси: ```js const a = () => {}; const b = () => {}; console.log(a === b); // false ``` `React.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, коли колбек захоплює стан або пропси** ```jsx // Неправильно: user.id завжди матиме значення з моменту монтування const save = useCallback(() => api.save(user.id), []); // Правильно const save = useCallback(() => api.save(user.id), [user.id]); ``` З `[]` замикання (closure) захоплює початковий `user.id` і ігнорує всі оновлення. Класичний баг застарілого замикання (stale closure). **2. Обгортка функцій, які не передаються як пропси** ```jsx // Неправильно: жоден мемоізований компонент це не отримує, нульова вигода const formatDate = useCallback((d) => d.toISOString(), []); // Правильно: звичайна функція const formatDate = (d) => d.toISOString(); ``` Платиш за порівняння deps при кожному рендері і нічого не отримуєш. **3. Об'єкт або масив у масиві залежностей** ```jsx // Неправильно: 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. Застаріле замикання в таймерах** ```jsx // БАГ: захоплює 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 кешує результати за референсом аргументу. Нестабільний тригер створює новий ключ кешу при кожному рендері, що призводить до промахів кешу і ламає оптимістичні оновлення. Стабільний референс тримає влучання в кеш і дає оптимістичному флоу працювати коректно. ## Приклади ### Мемоізований дочірній компонент пропускає рендер ```jsx 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 ```jsx 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` реально змінюється. ### Застаріле замикання в таймері ```jsx 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` перестворюватиметься при зміні затримки, і інтервал використовуватиме правильне значення.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.