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в одному обробнику дають один рендер.
Два правила:
- Хуки викликають лише на верхньому рівні компонента чи власного хука: не в умовах, циклах, вкладених функціях, після раннього
return. - Хуки викликають лише з функціональних компонентів і власних хуків, не зі звичайних функцій чи класів.
Чому: 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).
Для доступності елементи форм зв'язують через 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-' }) і такий самий на сервері.