# React Live Coding із TypeScript: 3 типові задачі з розбором рішень

> Три задачі, які реально дають на React live coding: список із пошуком, асинхронне завантаження зі станами, форма з валідацією. Слабкий і сильний варіанти рішення з TypeScript і поясненням, що оцінює інтерв’юер.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-09-24
- Категорія: React і Frontend
- Canonical: https://cookiesoftware.io/blog/react-live-coding-typescript

---

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

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

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

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

Загальний формат підготовки до React-співбесіди – окремою статтею: [React співбесіда: як підготуватися](/blog/react-interview-guide). Тут – практика.

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

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

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

```tsx
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` застаріє); пошук чутливий до регістру; інпут неконтрольований, стан запиту ніде не зберігається.

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

```tsx
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](https://react.dev/learn/you-might-not-need-an-effect).

На питання «а якщо список на 100 тисяч елементів?» відповідь – не хапатися за `useMemo` одразу, а сказати: спершу виміряти; якщо фільтрація реально дорога – мемоізувати; якщо рендер довгий – віртуалізація. Про це окремо: [оптимізація React без передчасного useMemo](/blog/optymizatsiia-react-performance).

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

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

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

```tsx
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) не існували в принципі:

```tsx
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](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)); порожній список – окремий стан, а не «баг без даних».

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

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

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

```tsx
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](/blog/typescript-generics).

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

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

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

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

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

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