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

React: питання на співбесіді рівня Junior

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

33 питань

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

JSX - синтаксичне розширення JavaScript, що дозволяє писати розмітку прямо в коді. Браузер його не розуміє: компілятор (Babel, esbuild, SWC, TypeScript) перетворює JSX на виклики функцій.

const element = <h1 className="title">Привіт, {user.name}</h1>;

// після компіляції (новий JSX transform)
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('h1', { className: 'title', children: ['Привіт, ', user.name] });

Результат - звичайний об'єкт-опис («React element»), а не DOM-вузол. React потім вирішує, як привести DOM у відповідність.

Відмінності від HTML:

  • className замість class, htmlFor замість for - бо це JavaScript-властивості.
  • Атрибути в camelCase: onClick, tabIndex; style - об'єкт: style={{ marginTop: 8 }}.
  • Кожен тег закривається: <img />, <br />.
  • Компонент повертає один корінь. Для кількох елементів - фрагмент <>...</>.
  • У фігурних дужках - вирази, не інструкції: {isAdmin && <Badge />}, {items.map(...)}, але не if чи for.

Безпека: значення в {} автоматично екрануються, тож рядок з <script> виведеться як текст. Небезпечний лише явний dangerouslySetInnerHTML.

Компонент - функція, що повертає JSX. Назва з великої літери обов'язкова: <profile /> React вважатиме HTML-тегом, <Profile /> - компонентом.

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

Під час оновлення React порівнює новий список елементів зі старим. key каже, який елемент нового списку відповідає якому елементу старого - щоб зберегти його стан і DOM.

<ul>
  {todos.map((todo) => (
    <TodoItem key={todo.id} todo={todo} />
  ))}
</ul>

Без key React виводить попередження й зіставляє елементи за позицією.

З індексом як key - те саме зіставлення за позицією, лише без попередження. Поки список лише дописують у кінець, проблем немає. Після видалення, вставки на початок чи сортування:

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

Правила для key:

  • Стабільний: той самий для того самого елемента між рендерами - id з бази.
  • Унікальний серед сусідів (не глобально).
  • Не генерувати під час рендеру: key={Math.random()} чи crypto.randomUUID() змушують React щоразу перестворювати всі елементи й губити стан. Якщо в даних немає ID, його генерують один раз - при створенні запису.

Корисний прийом: зміна key компонента примусово перестворює його з чистим станом: <Profile key={userId} userId={userId} /> - форма скинеться при переході до іншого користувача.

Докладніше в документації: Рендер списків: key

У JSX немає директив на кшталт v-if - умови пишуть звичайним JavaScript.

Тернарний оператор - один із двох варіантів:

{isLoggedIn ? <UserMenu /> : <LoginButton />}

&& - показати або нічого:

{hasError && <ErrorMessage />}

Ранній return - для цілих станів компонента:

if (isLoading) return <Spinner />;
if (!user) return null;   // null - нічого не рендерити
return <Profile user={user} />;

Пастка з && і числами:

{items.length && <List items={items} />}

Якщо масив порожній, items.length дорівнює 0. Оператор && повертає ліву частину, коли вона хибна, - тобто 0. А React рендерить число 0 як текст. Користувач бачить загадковий «0» на сторінці.

React не рендерить лише null, undefined, false, true і порожній рядок. Число 0 і NaN він показує.

Як правильно:

{items.length > 0 && <List items={items} />}
{!!items.length && <List items={items} />}
{items.length ? <List items={items} /> : null}

Ліва частина && має бути логічним значенням, а не просто «чимось, що буває хибним».

Інші поради:

  • складні умови - у змінну чи окремий компонент, а не вкладені тернарні оператори в JSX;
  • мапа станів замість ланцюжка умов:
const content = {
  loading: <Spinner />,
  error: <ErrorMessage />,
  empty: <EmptyState />,
}[status] ?? <List items={items} />;
  • умова змінює дерево: якщо в гілках однакові компоненти на тому самому місці, React може зберегти їхній стан між гілками. Коли стан має скидатися, різні гілки отримують різний key.

Приховати, а не прибрати - hidden чи CSS-клас: компонент і його стан лишаються. У React 19.2+ для цього є й <Activity mode="hidden">.

Докладніше в документації: Умовний рендеринг

Props - аргументи компонента. Батько передає їх як атрибути JSX, дитина отримує одним об'єктом (зазвичай з деструктуризацією):

function Avatar({ user, size = 48 }) {
  return <img src={user.avatarUrl} alt={user.name} width={size} height={size} />;
}

<Avatar user={currentUser} size={64} />

Правила props:

  • лише для читання - компонент не змінює свої props. Якщо значення має змінюватися, це стан (у батька чи в самому компоненті);
  • значення за замовчуванням - у деструктуризації (size = 48); спрацьовує для undefined, але не для null;
  • розгортання <Avatar {...props} /> зручне для обгорток, але приховує, що саме передається, - використовувати помірно.

children - вміст між тегами компонента:

function Card({ title, children }) {
  return (
    <section className="rounded border p-4">
      <h2>{title}</h2>
      {children}
    </section>
  );
}

<Card title="Профіль">
  <Avatar user={user} />
  <p>{user.bio}</p>
</Card>

Композиція замість успадкування. У React не створюють class SpecialCard extends Card. Спеціалізація - через props і вкладення:

  • «слоти» - кілька props з JSX: <Layout sidebar={<Nav />} header={<Header />}>...</Layout>;
  • спеціалізований компонент рендерить загальний з потрібними props: function WarningCard(props) { return <Card {...props} tone="warning" />; };
  • поведінку перевикористовують через власні хуки, а не базові класи.

Чому це краще: компоненти незалежні, їх легко комбінувати в будь-якому порядку, а зміни в «базовому» компоненті не ламають ієрархію нащадків.

Корисний наслідок children для продуктивності: якщо дорогий компонент передано як children, батько, що змінює свій стан, не перерендерює його - JSX-елемент створено вище й не змінився.

Prop drilling - коли дані передаються через багато рівнів, які їх не використовують. Спершу варто спробувати композицію (передати готовий JSX вниз), а вже потім Context.

Докладніше в документації: Передача props компоненту

Компонент повертає один кореневий елемент JSX. Щоб повернути кілька елементів без зайвої обгортки в DOM, використовують Fragment:

function UserInfo({ user }) {
  return (
    <>
      <dt>Ім'я</dt>
      <dd>{user.name}</dd>
    </>
  );
}

<>...</> - скорочений запис <Fragment>...</Fragment>. У DOM потрапляють лише dt і dd.

Навіщо не обгортати в <div>:

  • валідний HTML: усередині <dl>, <ul>, <table>, <tr> допустимі лише певні елементи. <div> між <tr> і <td> - некоректна розмітка й попередження React;
  • CSS-розкладка: у flex- чи grid-контейнері зайвий <div> стає окремим елементом сітки й ламає розташування;
  • менше DOM - трохи легше для браузера на великих сторінках.

Fragment з key - коли фрагмент рендериться в циклі. Скорочений запис <> атрибутів не приймає, потрібна повна форма:

import { Fragment } from 'react';

function Glossary({ items }) {
  return (
    <dl>
      {items.map((item) => (
        <Fragment key={item.id}>
          <dt>{item.term}</dt>
          <dd>{item.description}</dd>
        </Fragment>
      ))}
    </dl>
  );
}

Що варто знати:

  • key - єдиний атрибут, який приймає Fragment (в експериментальних версіях з'являється ще ref);
  • Fragment не має власного DOM-вузла - на нього не можна повісити обробник події чи клас;
  • збереження стану: React однаково трактує <> з дітьми і масив дітей на верхньому рівні, тож перехід між <><Child /></> і <Child /> не скидає стан. А от зміна позиції в дереві чи key - скидає.

Повернути масив (return [<li key="a" />, <li key="b" />]) теж можна, але потрібні key на кожному елементі - Fragment читається простіше.

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

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

Було - кожна панель сама вирішує, чи відкрита:

function Panel({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  // ...
}

Вимога «відкрита лише одна панель одночасно» так не реалізується: панелі не знають одна про одну.

Стало - стан у батька, панелі керовані:

function Accordion() {
  const [openId, setOpenId] = useState('about');

  return (
    <>
      <Panel title="Про нас" isOpen={openId === 'about'} onOpen={() => setOpenId('about')}>...</Panel>
      <Panel title="Доставка" isOpen={openId === 'delivery'} onOpen={() => setOpenId('delivery')}>...</Panel>
    </>
  );
}

function Panel({ title, isOpen, onOpen, children }) {
  return (
    <section>
      <button onClick={onOpen}>{title}</button>
      {isOpen && children}
    </section>
  );
}

Panel став керованим (controlled): ним керує батько через props. Компонент зі своїм внутрішнім станом - некерований (uncontrolled).

Принцип «одного джерела правди»: для кожного шматка стану є один компонент-власник. Інші отримують значення через props і повідомляють про зміни через колбеки.

Куди піднімати:

  • до найближчого спільного предка всіх, хто використовує дані, - не вище. Стан занадто високо змушує перерендерюватися велику частину дерева й ускладнює компоненти;
  • якщо спільний предок дуже далеко і дані треба передавати через багато рівнів («prop drilling»), - спершу композиція (передати готовий JSX через children), потім Context.

Зворотний процес теж корисний: якщо стан використовується лише в одному компоненті, його варто опустити туди (state colocation) - менше зайвих рендерів і простіші батьки.

Типова помилка - дублювати стан: копіювати props у локальний стан дитини (useState(props.value)). Копія не оновиться, коли батько змінить значення. Або компонент керований (значення з props), або некерований (власний стан з початковим значенням), - змішування дає розсинхронізацію.

Докладніше в документації: Спільний стан між компонентами

Надлишковий стан - значення, яке можна обчислити з props чи іншого стану. Його зберігання - джерело помилок, бо дві копії правди рано чи пізно розходяться.

Погано:

const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');   // надлишкове

function handleFirstNameChange(e) {
  setFirstName(e.target.value);
  setFullName(e.target.value + ' ' + lastName);   // треба не забути оновити тут
}

Добре - обчислювати під час рендеру:

const fullName = `${firstName} ${lastName}`;

Типові випадки надлишкового стану:

  • відфільтрований чи відсортований список поруч з оригіналом:
const visibleTodos = todos.filter((t) => (showDone ? true : !t.done));
  • кількості й суми: items.length, items.reduce(...);
  • обраний елемент як копія об'єкта - краще зберігати selectedId і знаходити об'єкт: items.find((i) => i.id === selectedId). Інакше зміна елемента в списку не потрапить у «обраний»;
  • прапорці, що випливають з даних: isEmpty, hasErrors, canSubmit.

Антипатерн - синхронізація ефектом:

const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
  setVisibleTodos(todos.filter(...));
}, [todos]);

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

А якщо обчислення дороге? Спершу виміряти. Якщо справді повільно - useMemo (або React Compiler зробить це автоматично):

const visibleTodos = useMemo(() => filterTodos(todos, query), [todos, query]);

Коли копія з props доречна: коли це початкове значення, яке далі живе своїм життям, - і тоді props варто назвати відповідно (initialColor, defaultValue), щоб було зрозуміло, що подальші зміни props не враховуються.

Перевірка структури стану: для кожного значення в useState запитати: «чи можна його обчислити з інших?». Якщо так - це не стан.

Докладніше в документації: Вибір структури стану

Найпростіше завантаження в ефекті:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let ignore = false;

    fetch(`/api/users/${userId}`)
      .then((response) => response.json())
      .then((data) => {
        if (!ignore) setUser(data);
      });

    return () => {
      ignore = true;
    };
  }, [userId]);

  if (!user) return <Spinner />;
  return <h1>{user.name}</h1>;
}

Навіщо змінна ignore. Користувач швидко перемикає профілі: userId 1, потім 2. Летять два запити. Якщо відповідь для 1 прийде пізніше за відповідь для 2, без перевірки вона перезапише стан - на екрані профіль 2-го користувача з даними 1-го. Функція очищення попереднього ефекту ставить ignore = true, і застаріла відповідь ігнорується.

Ще краще - скасувати запит:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((error) => {
      if (error.name !== 'AbortError') setError(error);
    });
  return () => controller.abort();
}, [userId]);

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

Чого бракує цьому підходу (і чому документація React радить не писати таке вручну у великих застосунках):

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

Альтернативи:

  • фреймворк (Next.js, React Router з завантажувачами) - дані завантажуються до рендеру маршруту;
  • TanStack Query чи SWR - кеш, повтори, скасування, синхронізація;
  • use() з Suspense і промісом зі стабільного джерела.

useEffect для даних лишається прийнятним для невеликих застосунків і простих випадків - але з очищенням.

Докладніше в документації: Синхронізація з ефектами: завантаження даних

Для стану, яким користуються компоненти на різних рівнях дерева (кошик, список завдань, налаштування), React пропонує поєднання useReducer + Context без сторонніх бібліотек.

const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, []);

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        {children}
      </TasksDispatchContext>
    </TasksContext>
  );
}

export function useTasks() {
  const tasks = useContext(TasksContext);
  if (tasks === null) throw new Error('useTasks має бути всередині TasksProvider');
  return tasks;
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

У React 19 сам контекст можна рендерити як провайдер (<TasksContext value={...}>); старий запис <TasksContext.Provider> теж працює.

Використання в будь-якому компоненті під провайдером:

function AddTask() {
  const dispatch = useTasksDispatch();
  return <button onClick={() => dispatch({ type: 'added', text: 'Нове завдання' })}>Додати</button>;
}

function TaskList() {
  const tasks = useTasks();
  return tasks.map((task) => <Task key={task.id} task={task} />);
}

Чому два контексти, а не один:

  • dispatch стабільний - не змінюється між рендерами;
  • компоненти, яким потрібна лише відправка дій (кнопки, форми), підписуються лише на TasksDispatchContext і не перерендерюються, коли змінюється список завдань.

Переваги підходу:

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

Обмеження, через які переходять на сховища:

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

Для частих оновлень і великого спільного стану - Zustand, Redux Toolkit чи подібні з підписками на частину стану.

Докладніше в документації: Масштабування з reducer і context

Неконтрольоване поле зберігає значення саме, у DOM. React лише задає початкове значення:

<input name="title" defaultValue="Чернетка" />

Значення читають у момент відправки - з FormData чи через ref.

Контрольоване поле - значення живе в стані React, а поле лише показує його:

const [title, setTitle] = useState('');

<input value={title} onChange={(e) => setTitle(e.target.value)} />

Кожне натискання клавіші - onChange → новий стан → новий рендер з новим value.

Коли контрольоване:

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

Коли неконтрольоване:

  • значення потрібне лише при відправці - звичайна форма з <form action> чи onSubmit і FormData;
  • великі форми, де рендер на кожну літеру помітно гальмує;
  • <input type="file"> - завжди неконтрольоване: значення файлового поля задати з коду не можна.

Правила, які не можна порушувати:

  • поле не може бути одночасно контрольованим і неконтрольованим, і не повинне перемикатися між режимами. Типова причина - value={user.name}, де name спершу undefined: поле стартує неконтрольованим, а потім стає контрольованим. Початкове значення - порожній рядок '', а не null чи undefined;
  • для чекбоксів - checked/defaultChecked, а не value.

Бібліотеки форм (React Hook Form) за замовчуванням працюють з неконтрольованими полями саме заради продуктивності, а стан помилок і «брудності» ведуть окремо.

Докладніше в документації: input: керування полем через стан

Питання рівня Junior з реальних технічних співбесід - 33 питання у 8 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Middle 34 Senior 33

Готуєтесь до співбесіди не просто так: зараз на сайті 7 відкритих вакансій рівня Junior. Переглянути вакансії