Коротка відповідь. На React live coding оцінюють не «чи дійшов до кінця», а як ти рухаєшся: чи уточнюєш вимоги, чи пишеш спершу простий робочий варіант, чи бачиш edge cases і чи можеш пояснити кожен рядок. Нижче – три найтиповіші задачі з двома рівнями рішення: «здав» і «здав переконливо». Код – TypeScript, можна запускати як є.
Що реально оцінюють
Чотири речі, у порядку важливості:
- Робочий код. Недописана «ідеальна архітектура» програє простому робочому рішенню.
- Комунікація. Ти проговорюєш план, обмеження і компроміси, а не мовчки друкуєш.
- Edge cases. Порожні стани, помилки мережі, дублікати – сам, без нагадування.
- Типізація. Не
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.
Як комунікувати під час рішення
Скелет, який працює на будь-якій задачі:
- Уточни (30 секунд): звідки дані, чи потрібна обробка помилок, чи важлива продуктивність.
- Назви план: «спершу зроблю робочу версію без стилів, потім додам стани помилок».
- Пиши і коментуй: «тут свідомо не мемоізую – список маленький».
- Сам назви слабкі місця: «у production я б додав debounce на пошук і тест на порожній стан».
Останній пункт – найдешевший спосіб виглядати сильніше: ти забираєш в інтерв'юера його улюблені питання. Що саме тестувати в таких задачах – окрема стаття: тести для live-coding задач.
Практичне завдання
Візьми задачу 2 і додай до неї: кнопку «Спробувати ще раз» у стані помилки, debounce-пошук по завантажених продуктах і тест на те, що при HTTP 500 показується повідомлення про помилку. Обмеж себе 40 хвилинами з таймером – це чесна симуляція реальної співбесіди.