Коротка відповідь. На React live coding оцінюють не «чи дійшов до кінця», а як ти рухаєшся: чи уточнюєш вимоги, чи пишеш спершу простий робочий варіант, чи бачиш edge cases і чи можеш пояснити кожен рядок. Нижче – три найтиповіші задачі з двома рівнями рішення: «здав» і «здав переконливо». Код – TypeScript, можна запускати як є.

Що реально оцінюють

Чотири речі, у порядку важливості:

  1. Робочий код. Недописана «ідеальна архітектура» програє простому робочому рішенню.
  2. Комунікація. Ти проговорюєш план, обмеження і компроміси, а не мовчки друкуєш.
  3. Edge cases. Порожні стани, помилки мережі, дублікати – сам, без нагадування.
  4. Типізація. Не any, а типи, які реально ловлять помилки.

Загальний формат підготовки до React-співбесіди – окремою статтею: React співбесіда: як підготуватися. Тут – практика.

Задача 1 – список із пошуком

Умова: «Є масив користувачів. Виведи список і додай пошук за іменем».

Слабший варіант, який часто пишуть під стресом:

function UserList({ users }: { users: { id: string; name: string }[] }) {
  const [filtered, setFiltered] = useState(users);

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    setFiltered(users.filter((u) => u.name.includes(e.target.value)));
  }

  return (
    <>
      <input onChange={handleSearch} />
      <ul>{filtered.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
    </>
  );
}

Працює, але має три проблеми, про які спитають: відфільтрований список продубльований у стані (два джерела правди – якщо users зміниться, filtered застаріє); пошук чутливий до регістру; інпут неконтрольований, стан запиту ніде не зберігається.

Сильніший варіант – зберігати запит, а список рахувати:

import { useState } from 'react';

type User = { id: string; name: string };

function UserList({ users }: { users: User[] }) {
  const [query, setQuery] = useState('');

  const normalized = query.trim().toLowerCase();
  const visible = normalized
    ? users.filter((user) => user.name.toLowerCase().includes(normalized))
    : users;

  return (
    <>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Пошук за іменем"
        aria-label="Пошук за іменем"
      />
      {visible.length === 0 ? (
        <p>Нічого не знайдено за запитом «{query}»</p>
      ) : (
        <ul>
          {visible.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </>
  );
}

Що тут «продає» рішення: єдине джерело правди (query – стан, visible – похідне значення, яке не треба синхронізувати); порожній стан; нормалізація регістру; доступний інпут. Похідні значення замість дубльованого стану – одна з головних ідей, які перевіряють; чому це так, добре пояснено в react.dev.

На питання «а якщо список на 100 тисяч елементів?» відповідь – не хапатися за useMemo одразу, а сказати: спершу виміряти; якщо фільтрація реально дорога – мемоізувати; якщо рендер довгий – віртуалізація. Про це окремо: оптимізація React без передчасного useMemo.

Задача 2 – асинхронне завантаження зі станами

Умова: «Завантаж список з API і виведи його». Пастка в тому, що оцінюють не fetch, а стани.

Слабший варіант ігнорує все, крім happy path:

function Products() {
  const [items, setItems] = useState<Product[]>([]);

  useEffect(() => {
    fetch('/api/products')
      .then((res) => res.json())
      .then(setItems);
  }, []);

  return <ul>{items.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Питання, які його «топлять»: що бачить користувач перші 500 мс? Що станеться при 500-ці від сервера? А якщо компонент розмонтується до відповіді?

Сильніший варіант моделює стан явно – одним об'єктом, щоб неможливі комбінації (одночасно loading і error) не існували в принципі:

import { useEffect, useState } from 'react';

type Product = { id: string; title: string };

type LoadState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'ready'; items: Product[] };

function Products() {
  const [state, setState] = useState<LoadState>({ status: 'loading' });

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        const res = await fetch('/api/products', { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const items: Product[] = await res.json();
        setState({ status: 'ready', items });
      } catch (error) {
        if (controller.signal.aborted) return;
        setState({ status: 'error', message: 'Не вдалося завантажити список' });
      }
    }

    load();
    return () => controller.abort();
  }, []);

  if (state.status === 'loading') return <p>Завантаження…</p>;
  if (state.status === 'error') return <p role="alert">{state.message}</p>;
  if (state.items.length === 0) return <p>Список порожній</p>;

  return (
    <ul>
      {state.items.map((product) => (
        <li key={product.id}>{product.title}</li>
      ))}
    </ul>
  );
}

Ключові фрази, які варто проговорити вголос: discriminated union робить неможливі стани непредставимими; AbortController у cleanup прибирає гонку «відповідь прийшла після розмонтування»; перевірка res.ok, бо fetch не кидає помилку на HTTP 500 (деталі – у документації MDN); порожній список – окремий стан, а не «баг без даних».

Задача 3 – форма з валідацією

Умова: «Форма: email і пароль, кнопка активна лише коли все валідно, помилки під полями».

Тут перевіряють роботу з подіями та похідним станом. Компактне сильне рішення:

import { useState } from 'react';

type Touched = { email: boolean; password: boolean };

function validateEmail(value: string): string | null {
  if (!value.trim()) return 'Вкажи email';
  if (!value.includes('@')) return 'Схоже, це не email';
  return null;
}

function validatePassword(value: string): string | null {
  if (value.length < 8) return 'Мінімум 8 символів';
  return null;
}

export function LoginForm({ onSubmit }: { onSubmit: (email: string, password: string) => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [touched, setTouched] = useState<Touched>({ email: false, password: false });

  const emailError = validateEmail(email);
  const passwordError = validatePassword(password);
  const isValid = !emailError && !passwordError;

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (isValid) onSubmit(email, password);
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          onBlur={() => setTouched((prev) => ({ ...prev, email: true }))}
        />
      </label>
      {touched.email && emailError && <p role="alert">{emailError}</p>}

      <label>
        Пароль
        <input
          type="password"
          value={password}
          onChange={(event) => setPassword(event.target.value)}
          onBlur={() => setTouched((prev) => ({ ...prev, password: true }))}
        />
      </label>
      {touched.password && passwordError && <p role="alert">{passwordError}</p>}

      <button type="submit" disabled={!isValid}>
        Увійти
      </button>
    </form>
  );
}

Про що спитають і що відповідати:

  • Чому помилки не в стані? Вони – чиста функція від значення поля. Стан лише для того, що не можна порахувати: введені значення і touched.
  • Навіщо touched? Щоб не кричати «Вкажи email» на порожній формі, якої користувач ще не торкався.
  • Чому onBlur, а не показ помилки одразу? UX-компроміс; важливо, що ти його називаєш свідомо, а не «так вийшло».
  • Типи подій? React.ChangeEvent<HTMLInputElement> для input, React.FormEvent<HTMLFormElement> для submit. Дженерики тут – параметр елемента; глибше про це у статті про TypeScript generics.

Як комунікувати під час рішення

Скелет, який працює на будь-якій задачі:

  1. Уточни (30 секунд): звідки дані, чи потрібна обробка помилок, чи важлива продуктивність.
  2. Назви план: «спершу зроблю робочу версію без стилів, потім додам стани помилок».
  3. Пиши і коментуй: «тут свідомо не мемоізую – список маленький».
  4. Сам назви слабкі місця: «у production я б додав debounce на пошук і тест на порожній стан».

Останній пункт – найдешевший спосіб виглядати сильніше: ти забираєш в інтерв'юера його улюблені питання. Що саме тестувати в таких задачах – окрема стаття: тести для live-coding задач.

Практичне завдання

Візьми задачу 2 і додай до неї: кнопку «Спробувати ще раз» у стані помилки, debounce-пошук по завантажених продуктах і тест на те, що при HTTP 500 показується повідомлення про помилку. Обмеж себе 40 хвилинами з таймером – це чесна симуляція реальної співбесіди.