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

Junior: питання на співбесіді з теми «Хуки»

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

5 питань

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