Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Хуки

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

15 питань

setCount(count + 1) не змінює змінну count - він планує новий рендер, у якому useState поверне нове значення. У поточному виконанні функції count лишається тим, яким був на початку рендеру. Стан - це «знімок» для конкретного рендеру.

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    console.log(count); // 0 - ще старий знімок
  }
  // після кліку count стане 1, а не 2
}

Обидва виклики порахували 0 + 1.

Функціональне оновлення - передати функцію, яка отримає актуальне значення з черги оновлень:

setCount((c) => c + 1);
setCount((c) => c + 1); // тепер 2

Коли потрібне: коли нове значення залежить від попереднього і оновлень може бути кілька поспіль, а також в асинхронному коді й таймерах, де count у замиканні може бути застарілим.

Ще правила стану:

  • Не змінювати стан на місці: items.push(x); setItems(items) - React порівнює посилання, бачить той самий масив і може не перерендерити. Правильно: setItems([...items, x]).
  • React групує оновлення (batching): кілька setState в одному обробнику дають один рендер.

Докладніше в документації: useState

Два правила:

  1. Хуки викликають лише на верхньому рівні компонента чи власного хука: не в умовах, циклах, вкладених функціях, після раннього return.
  2. Хуки викликають лише з функціональних компонентів і власних хуків, не зі звичайних функцій чи класів.

Чому: React не знає імен ваших станів. Він зберігає стан компонента як список і зіставляє виклики хуків з елементами списку за порядком: перший useState - перший елемент, другий - другий. Якщо на одному рендері виклик пропустили через умову, усі наступні хуки «з'їдуть» на чужі значення.

// Неправильно
if (isLoggedIn) {
  const [name, setName] = useState('');
}
const [theme, setTheme] = useState('light'); // візьме стан name, якщо умова змінилася

// Правильно: умова всередині, хук - завжди
const [name, setName] = useState('');
const displayName = isLoggedIn ? name : 'Гість';

Як не помилитися: плагін eslint-plugin-react-hooks перевіряє обидва правила й залежності useEffect. Його варто тримати увімкненим завжди.

Виняток: хук use (React 19) можна викликати в умовах і циклах - він читає Promise чи контекст і не зберігає стан у списку.

Докладніше в документації: Правила хуків

React вирішує, чи перерендерити компонент, порівнюючи посилання: новий стан має бути новим об'єктом. Зміна старого об'єкта «на місці» не помітна для React - і ламає логіку порівняння в memo, useEffect, useMemo.

Об'єкти - копія з розгортанням:

const [user, setUser] = useState({ name: 'Оля', address: { city: 'Київ', street: '' } });

// неправильно: мутація
user.address.city = 'Львів';
setUser(user);

// правильно: нові об'єкти на кожному рівні шляху до зміни
setUser({
  ...user,
  address: { ...user.address, city: 'Львів' },
});

Розгортання (...) поверхневе - копіювати треба кожен рівень, що змінюється.

Масиви - методи, що повертають новий масив:

Дія Замість мутації Без мутації
додати push, unshift [...items, item]
видалити splice items.filter((i) => i.id !== id)
змінити items[i] = x items.map((i) => (i.id === id ? { ...i, done: true } : i))
вставити splice [...items.slice(0, n), item, ...items.slice(n)]
сортувати sort, reverse items.toSorted(...), items.toReversed()

Пастка зі «скопійованим» масивом: [...items] копіює масив, але елементи-об'єкти лишаються ті самі. copy[0].done = true змінює об'єкт і в старому стані.

Глибоко вкладений стан робить такі оновлення громіздкими. Два виходи:

  • Immer (use-immer): пишете «мутацію», а бібліотека створює новий незмінний об'єкт:
updateUser((draft) => {
  draft.address.city = 'Львів';
});
  • сплощити стан - зберігати сутності за id в окремих полях замість глибокої вкладеності.

Функціональне оновлення для залежності від попереднього стану: setItems((prev) => [...prev, item]).

Чому це важливо не лише для рендеру: незмінність дає «знімки» історії (скасування дій), дешеве порівняння для оптимізацій і передбачувані ефекти.

Докладніше в документації: Оновлення об'єктів у стані

useEffect синхронізує компонент із зовнішньою системою: підписка, таймер, WebSocket, сторонній віджет, запит до API.

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();

  return () => connection.disconnect();   // очищення
}, [roomId]);

Масив залежностей визначає, коли ефект перезапускається:

Варіант Коли виконується
без масиву після кожного рендеру
[] один раз після монтування (і очищення при розмонтуванні)
[roomId] після монтування і щоразу, коли roomId змінився

Значення порівнюються через Object.is: для об'єктів і функцій, створених під час рендеру, кожен рендер - «нове» значення, і ефект перезапускається щоразу.

Функція очищення виконується:

  • перед кожним повторним запуском ефекту - зі старими значеннями (відключитися від старої кімнати перед підключенням до нової);
  • при розмонтуванні компонента.

Без очищення - витоки: таймери продовжують працювати, підписки накопичуються, відповіді на старі запити перезаписують нові дані.

Залежності не обирають - їх перелічують. Кожне реактивне значення (props, стан, змінні й функції з тіла компонента), що використовується в ефекті, має бути в масиві. Правило лінтера react-hooks/exhaustive-deps перевіряє це автоматично. Якщо ефект перезапускається надто часто, рішення - змінити код (винести функцію всередину ефекту, функціональне оновлення стану, useEffectEvent), а не викинути залежність з масиву.

StrictMode в режимі розробки монтує компонент двічі: ефект → очищення → ефект. Це навмисна перевірка, що очищення написане правильно. Якщо від цього щось ламається (подвійний запит із побічним ефектом, подвійна аналітика), - бракує очищення.

Коли ефект не потрібен: обчислення з props чи стану (рахувати під час рендеру), реакція на дію користувача (робити в обробнику події), скидання стану при зміні props (через key).

Докладніше в документації: useEffect

Для доступності елементи форм зв'язують через id: <label htmlFor>, aria-describedby, aria-labelledby. Але компонент поля може бути на сторінці кілька разів - жорстко прописаний id="email" дублюватиметься.

useId генерує унікальний ідентифікатор для кожного екземпляра компонента:

import { useId } from 'react';

function PasswordField({ label, hint }) {
  const id = useId();

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="password" aria-describedby={`${id}-hint`} />
      <p id={`${id}-hint`}>{hint}</p>
    </>
  );
}

Один виклик - базовий id, а пов'язані ідентифікатори утворюють з суфіксами.

Чому не Math.random() чи лічильник:

  • серверний рендер: HTML генерується на сервері, потім React «гідратує» його в браузері. Math.random() дасть на клієнті інший id, ніж на сервері, - помилка розбіжності гідратації, а зв'язки label-input зламаються;
  • кожен рендер - новий id, якщо генерувати в тілі компонента, і атрибути постійно змінюватимуться;
  • глобальний лічильник залежить від порядку рендеру, який на сервері й клієнті може відрізнятися.

useId будує ідентифікатор зі шляху компонента в дереві - він однаковий на сервері й клієнті і стабільний між рендерами.

Чого useId не робить:

  • не для key у списках - ключі мають походити з даних (item.id);
  • не для пошуку елементів через document.getElementById - для доступу до DOM є useRef;
  • не гарантує конкретного формату - не будуйте на ньому CSS-селектори.

Кілька застосунків React на одній сторінці (наприклад, «острівці» на серверній сторінці) можуть згенерувати однакові id. Для них задається префікс: createRoot(el, { identifierPrefix: 'search-' }) і такий самий на сервері.

Докладніше в документації: useId

useEffect - для синхронізації компонента з зовнішньою системою: підписка на WebSocket, таймер, сторонній віджет, ручна робота з DOM, запит даних.

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect();   // очищення
}, [roomId]);                              // залежності

Залежності: ефект перезапускається, коли змінилося будь-яке значення з масиву. В масиві мають бути всі реактивні значення, які ефект використовує, - лінтер react-hooks/exhaustive-deps за цим стежить. Порожній масив - лише після першого монтування.

Очищення виконується перед наступним запуском ефекту і при демонтуванні. Без нього - витоки: подвійні підписки, таймери, що живуть після компонента. У режимі розробки React навмисно монтує компонент двічі (StrictMode), щоб відсутність очищення стала помітною.

Коли ефект НЕ потрібен - найчастіша помилка:

  • Похідні дані. Не useEffect(() => setFullName(first + ' ' + last), [first, last]), а просто const fullName = first + ' ' + last під час рендеру. Дороге обчислення - useMemo.
  • Реакція на дію користувача. Відправка форми, аналітика кліку - в обробнику події, а не в ефекті, що стежить за станом.
  • Скидання стану при зміні props - через key на компоненті.

Запити даних в ефекті потребують обробки гонок (прапорець ignore в очищенні чи AbortController). У реальних застосунках для цього використовують TanStack Query, SWR або засоби фреймворку.

Докладніше в документації: Можливо, вам не потрібен ефект

useRef(initial) повертає об'єкт { current }, який живе весь час існування компонента. На відміну від стану, зміна ref.current не викликає рендер.

Два застосування:

  1. Доступ до DOM-елемента:
function Search() {
  const inputRef = useRef(null);

  return (
    <>
      <input ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>Шукати</button>
    </>
  );
}
  1. Значення, яке треба зберігати між рендерами, але не показувати: ID таймера, попереднє значення, прапорець «чи вже відправлено», екземпляр сторонньої бібліотеки.
const timerRef = useRef(null);

function start() {
  timerRef.current = setInterval(tick, 1000);
}

function stop() {
  clearInterval(timerRef.current);
}

Стан чи ref:

  • Значення впливає на те, що показано, - стан.
  • Значення потрібне лише логіці й не має перемальовувати компонент - ref.

Правила:

  • Не читати й не записувати ref.current під час рендеру (крім лінивої ініціалізації) - рендер має бути чистим, а зміна ref не відображається в UI.
  • Для передачі ref у власний компонент у React 19 ref - звичайний prop. Раніше для цього потрібен був forwardRef.

Докладніше в документації: useRef

useReducer виносить логіку оновлення стану в окрему функцію-редуктор: компонент лише описує, що сталося (дію), а редуктор вирішує, як змінюється стан.

function cartReducer(state, action) {
  switch (action.type) {
    case 'added':
      return { ...state, items: [...state.items, action.item] };
    case 'removed':
      return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case 'cleared':
      return { ...state, items: [] };
    default:
      throw new Error(`Невідома дія: ${action.type}`);
  }
}

function Cart() {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  return <button onClick={() => dispatch({ type: 'cleared' })}>Очистити</button>;
}

Коли useReducer кращий:

  • кілька пов'язаних значень змінюються разом (статус запиту, дані, помилка; кроки майстра);
  • багато різних оновлень одного стану розкидано по обробниках - редуктор збирає їх в одному місці;
  • наступний стан залежить від попереднього складним чином;
  • тестування: редуктор - чиста функція, її тестують без React: expect(cartReducer(state, action)).toEqual(...);
  • передача вниз: dispatch стабільний між рендерами - його безпечно передавати в дочірні компоненти й контекст без useCallback.

Коли досить useState: незалежні прості значення (відкрито/закрито, текст у полі).

Правила редуктора:

  • чистий: без запитів, таймерів, випадкових значень - React може викликати його двічі в StrictMode;
  • не мутує стан - повертає новий об'єкт;
  • дії описують подію ('added', 'login_failed'), а не «встановити поле» - тоді логіка зосереджена в редукторі, а не в компонентах.

Лінива ініціалізація - третій аргумент: useReducer(reducer, userId, createInitialState) - функція викличеться лише один раз.

Масштабування: useReducer + Context - простий спосіб керувати станом частини застосунку без зовнішніх бібліотек. Для складнішого - Redux Toolkit, ідеї якого побудовані на тих самих редукторах.

Докладніше в документації: useReducer

Обидва хуки мають однаковий API, різниця - коли вони виконуються відносно відмальовування екрана.

  • useEffect - після того, як браузер відмалював зміни. Не блокує показ - підходить для більшості ефектів (запити, підписки, логування);
  • useLayoutEffect - після оновлення DOM, але до відмальовування. Браузер чекає, поки ефект виконається.

Коли потрібен useLayoutEffect: коли треба виміряти DOM і одразу на основі вимірювання змінити розмітку - щоб користувач не побачив проміжного стану.

Класичний приклад - підказка (tooltip), яка має з'явитися над елементом, а якщо зверху немає місця - під ним:

function Tooltip({ targetRect, children }) {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    setTooltipHeight(ref.current.getBoundingClientRect().height);
  }, []);

  const top = targetRect.top - tooltipHeight < 0
    ? targetRect.bottom
    : targetRect.top - tooltipHeight;

  return <div ref={ref} style={{ position: 'fixed', top }}>{children}</div>;
}

З useEffect користувач на мить побачив би підказку не на тому місці, а потім вона «стрибнула» б. З useLayoutEffect вимірювання й перерахунок відбуваються до першого показу.

Інші випадки: відновлення позиції прокрутки, анімації, що стартують від виміряної позиції, синхронізація зі сторонніми бібліотеками, що вимірюють DOM.

Ціна: useLayoutEffect блокує відмальовування. Важка робота в ньому робить інтерфейс повільнішим. За замовчуванням - useEffect, а useLayoutEffect - лише коли видно «мерехтіння».

Серверний рендер: на сервері немає DOM і розкладки - ні useEffect, ні useLayoutEffect там не виконуються. Компонент, що залежить від вимірювань, на сервері рендериться без них; варіанти - показувати його лише на клієнті або мати розумний вигляд за замовчуванням.

useInsertionEffect - ще раніший хук, лише для бібліотек CSS-in-JS, що вставляють стилі до будь-яких вимірювань.

Докладніше в документації: useLayoutEffect

Проблема. Ефект має перезапускатися, коли змінюються одні значення, але використовує й інші, через які перезапускатися не повинен.

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => {
      showNotification('Підключено', theme);   // потрібна поточна тема
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);   // зміна теми перепідключає до чату!
}

Прибрати theme із залежностей не можна - буде застаріле значення, і лінтер це помітить. Додати - зайві перепідключення.

useEffectEvent (стабільний з React 19.2) виносить нереактивну частину логіки в «подію ефекту»: функцію, яка завжди бачить актуальні props і стан, але не є залежністю:

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Підключено', theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('connected', () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);   // лише справжня причина перепідключення
}

Як розрізняти:

  • реактивна логіка (має перезапускати синхронізацію) - у тілі ефекту, її значення в залежностях;
  • логіка-реакція (виконується в певний момент і має бачити свіжі значення) - у useEffectEvent.

Інші типові випадки: аналітика з поточними даними користувача при зміні сторінки, обробник інтервалу, що читає свіжий стан, колбек з props (onChange), який батько щоразу створює заново.

Обмеження:

  • викликати лише з ефектів - не з обробників подій і не під час рендеру;
  • не передавати в інші компоненти й хуки - функція оголошується поруч з ефектом, що її використовує;
  • це не спосіб «приглушити» лінтер: якщо значення справді має перезапускати ефект, воно лишається в залежностях.

До появи хука це розв'язували через useRef з останнім значенням колбеку - useEffectEvent робить те саме офіційно й безпечніше для конкурентного рендерингу.

Докладніше в документації: useEffectEvent

Власний хук - функція з назвою на use, що викликає інші хуки. Спосіб винести логіку зі станом і ефектами з компонента й перевикористати.

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebouncedValue(query);
  const results = useSearch(debouncedQuery);
  // ...
}

Ключове: хуки ділять логіку, а не стан. Кожен компонент, що викликає useDebouncedValue, отримує свій незалежний стан. Для спільного стану потрібен контекст чи стор.

Ознаки, що варто виділити хук:

  • той самий набір useState + useEffect повторюється в кількох компонентах;
  • ефект синхронізується з чимось зовнішнім (useOnlineStatus, useMediaQuery, useWebSocket) - хук ховає деталі за зрозумілою назвою;
  • компонент став довгим, і логіка заважає читати розмітку.

Чого уникати:

  • Хуків-обгорток життєвого циклу (useMount, useUpdateEffect): вони ховають залежності від лінтера й заохочують мислення класовими компонентами.
  • Хуків, що лише групують useState без поведінки - виграшу немає.
  • Нестабільних результатів: функції, які хук повертає й які передаватимуть у залежності ефектів, обгортають у useCallback, інакше ефекти споживачів перезапускатимуться на кожен рендер.

Тестують хуки через renderHook з React Testing Library.

Докладніше в документації: Перевикористання логіки через власні хуки

Context передає значення від провайдера до будь-якого нащадка без props на кожному рівні: тема, мова, поточний користувач, налаштування.

const ThemeContext = createContext('light');

<ThemeContext value={theme}>   {/* React 19; раніше ThemeContext.Provider */}
  <App />
</ThemeContext>

const theme = useContext(ThemeContext);

Чому всі споживачі перерендерюються: коли значення провайдера змінилося (порівняння через Object.is), React перерендерює кожен компонент, що читає цей контекст, навіть якщо використовує лише частину значення. Вибіркової підписки на поле в контексту немає.

Типова пастка - новий об'єкт на кожен рендер:

<AuthContext value={{ user, login, logout }}>

Кожен рендер провайдера створює новий об'єкт - і всі споживачі перерендерюються, навіть якщо user не змінився.

Як з цим жити:

  • Мемоізувати значення: const value = useMemo(() => ({ user, login, logout }), [user]), функції - через useCallback.
  • Розділяти контексти: дані, що часто змінюються, окремо від рідкісних; стан окремо від функцій-диспетчерів (StateContext і DispatchContext).
  • Не тримати в контексті часто змінюваний стан великої частини застосунку (значення полів форми, позиція курсору). Для такого краще стор з селекторами (Zustand, Redux, Jotai), де компонент підписується лише на потрібну частину.

Context - не менеджер стану, а механізм передачі. Він чудово працює для рідко змінюваних значень і погано масштабується для глобального стану, що часто оновлюється.

Докладніше в документації: Передача даних глибоко через Context

Більшість стану живе в React (useState, useReducer). Але інколи дані зберігаються зовні: у сторонній бібліотеці стану, в API браузера (navigator.onLine, matchMedia, localStorage), у власному класі-сховищі.

Наївний підхід - useEffect з підпискою і копією в useState - має проблеми в конкурентному рендерингу: React може рендерити частини дерева в різний час, і різні компоненти побачать різні версії зовнішнього значення («розрив», tearing).

useSyncExternalStore - офіційний спосіб читати зовнішнє джерело узгоджено:

import { useSyncExternalStore } from 'react';

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,   // знімок на клієнті
    () => true,               // знімок для серверного рендеру
  );
}

Три аргументи:

  1. subscribe(callback) - підписатися на зміни й повернути функцію відписки;
  2. getSnapshot() - поточне значення;
  3. getServerSnapshot() - значення для серверного рендеру й гідратації (на сервері немає navigator).

Ключові правила:

  • getSnapshot має повертати те саме значення, доки дані не змінилися. Якщо він щоразу створює новий об'єкт (() => ({ ...store.state })), React вважатиме, що дані змінилися на кожному рендері, - нескінченний цикл і помилка. Повертайте збережене незмінне значення або примітив;
  • subscribe - стабільна функція, оголошена поза компонентом (чи в useCallback), інакше React перепідписуватиметься на кожному рендері;
  • оновлення з такого джерела не можна позначити як transition - вони завжди синхронні.

Хто вже використовує: Redux (useSelector), Zustand, TanStack Query та інші бібліотеки стану побудовані на ньому. У застосунку цей хук зазвичай потрібен для API браузера чи власного сховища.

Приклад із localStorage: підписка на подію storage (зміни з інших вкладок) плюс власна подія для змін у поточній вкладці - і стан синхронізований між вкладками.

Докладніше в документації: useSyncExternalStore

use(resource) читає значення з проміса або контексту. На відміну від хуків, його можна викликати в умовах і циклах.

1. Читання проміса з Suspense:

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);   // «призупиняє» компонент до виконання проміса
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

function Post({ commentsPromise }) {
  return (
    <Suspense fallback={<p>Завантаження коментарів...</p>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

Поки проміс виконується, показується fallback найближчого <Suspense>. Відхилений проміс потрапляє в найближчу межу помилок (error boundary).

Головна пастка - звідки береться проміс. Проміс, створений під час рендеру клієнтського компонента (use(fetch('/api/comments'))), буде новим на кожному рендері - React показуватиме fallback знову й знову. Проміс має бути стабільним:

  • створений у серверному компоненті й переданий клієнтському як prop (основний сценарій у Next.js);
  • закешований бібліотекою (TanStack Query, роутер з завантажувачами даних);
  • створений поза рендером (в обробнику події, в завантажувачі маршруту).

2. Читання контексту:

function Toolbar({ showTheme }) {
  if (showTheme) {
    const theme = use(ThemeContext);   // useContext тут заборонений правилами хуків
    return <ThemeBadge theme={theme} />;
  }
  return null;
}

Що відрізняє use від хуків:

  • можна викликати умовно і в циклах;
  • але лише в компонентах і хуках - не в звичайних функціях і не в try/catch (помилки обробляє error boundary, а не catch);
  • не створює власного стану.

У серверних компонентах use для промісів не потрібен - там можна просто await. use - для клієнтських компонентів, що отримують проміс.

Чим це краще за useEffect для даних: немає проміжного стану «ще не завантажено» в кожному компоненті, немає гонитви запитів у ефектах, а стани завантаження й помилки декларативно задаються межами Suspense і error boundary.

Докладніше в документації: use

Кожен рендер компонента - окремий виклик функції зі своїми значеннями props і стану. Функції, створені під час рендеру (обробники, колбеки ефектів), «запам'ятовують» значення цього рендеру. Якщо функція живе довше за рендер, вона бачить старі значення - це застаріле замикання (stale closure).

Класичний приклад - інтервал:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);   // count завжди 0 - замикання першого рендеру
    }, 1000);
    return () => clearInterval(id);
  }, []);   // ефект створено один раз

  return <p>{count}</p>;   // зупиняється на 1
}

Способи виправити:

1. Функціональне оновлення - не читати стан у замиканні взагалі:

setCount((c) => c + 1);

Найкращий варіант, коли нове значення залежить лише від попереднього.

2. Додати значення в залежності - ефект перезапуститься з новим замиканням:

useEffect(() => { /* ... */ }, [count]);

Коректно, але для інтервалу означає перестворення таймера щосекунди.

3. useEffectEvent - логіка, що має бачити свіжі значення, але не перезапускати ефект:

const onTick = useEffectEvent(() => setCount(count + step));
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, []);

4. useRef з поточним значенням - старий спосіб (ref оновлюється на кожному рендері, колбек читає ref.current). Працює, але useEffectEvent робить це явніше.

Де ще трапляється:

  • обробники сторонніх бібліотек, зареєстровані один раз (карта, WebSocket);
  • useCallback з неповними залежностями - мемоізована функція бачить старий стан;
  • асинхронні функції: після await значення змінних - ті, що були на момент виклику, навіть якщо стан уже змінився.

Профілактика: правило лінтера react-hooks/exhaustive-deps знаходить більшість застарілих замикань у ефектах і мемоізації. Попередження «missing dependency» - майже завжди справжня помилка, а не надмірна прискіпливість лінтера.

Докладніше в документації: Видалення залежностей ефекту