Питання на співбесіді: TypeScript та інструменти
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Props компонента типізують звичайним типом чи інтерфейсом у параметрі функції:
type ButtonProps = {
label: string;
variant?: 'primary' | 'secondary'; // необов'язковий
onClick: () => void;
};
function Button({ label, variant = 'primary', onClick }: ButtonProps) {
return <button className={variant} onClick={onClick}>{label}</button>;
}
Значення за замовчуванням задають деструктуризацією - окремий defaultProps для функціональних компонентів у React 19 більше не підтримується.
children оголошують явно - як і будь-який інший prop:
import type { ReactNode } from 'react';
type CardProps = {
title: string;
children: ReactNode;
};
ReactNode чи ReactElement:
ReactNode- усе, що React уміє відрендерити: JSX, рядки, числа,bigint,null,undefined,boolean, масиви цього, а також проміси (дляuse). Правильний тип дляchildrenу більшості випадків;ReactElement- лише результат JSX (<div />,<Card />). Рядок чиnullтуди не передати. Доречний, коли компонент справді приймає рівно один елемент (наприклад, щоб клонувати його чи додати props).
<Card title="Профіль">Текст</Card> // ReactNode - ок
<Tooltip content="Підказка"><button /></Tooltip> // children: ReactElement
React.JSX.Element - тип, який повертає JSX-вираз. У React 19 глобального простору імен JSX більше немає: пишуть React.JSX.Element або імпортують JSX з react. Тип повернення компонента зазвичай не вказують - TypeScript виведе його сам.
React.FC у сучасному коді вживають рідко: він не додає користі порівняно з типізованим параметром, а історично неявно додавав children. Звичайна функція з типізованими props - стандарт.
Пастка: children: JSX.Element забороняє передати текст чи кілька елементів - помилка компіляції там, де її не очікують.
Докладніше в документації: TypeScript у React: типізація children
React передає в обробники синтетичні події - обгортки над подіями браузера з однаковою поведінкою в усіх браузерах. Для кожного виду події є свій тип, параметризований елементом.
Інлайн-обробник типізувати не треба - TypeScript виведе тип сам:
<input onChange={(event) => setName(event.target.value)} />
// event: React.ChangeEvent<HTMLInputElement>
Окрема функція потребує явного типу:
import type { ChangeEvent, FormEvent, MouseEvent } from 'react';
function handleChange(event: ChangeEvent<HTMLInputElement>) {
setEmail(event.target.value);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
}
function handleClick(event: MouseEvent<HTMLButtonElement>) {
console.log(event.clientX);
}
Найчастіші типи: ChangeEvent, FormEvent, MouseEvent, KeyboardEvent, FocusEvent, DragEvent, ClipboardEvent.
Тип самого обробника - зручно для props:
type SearchProps = {
onSearch: (query: string) => void; // власний колбек
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>; // обробник DOM-події
};
KeyboardEventHandler<T> - це (event: KeyboardEvent<T>) => void.
Як дізнатися правильний тип: навести курсор на onChange в JSX - редактор покаже очікувану сигнатуру.
target чи currentTarget:
currentTarget- елемент, на якому висить обробник, і його тип відомий (HTMLButtonElement);target- елемент, де виникла подія (може бути вкладений<span>усередині кнопки), тому дляMouseEventвін має загальний типEventTarget.
Для полів введення event.target.value працює, бо в ChangeEvent<HTMLInputElement> тип target уточнено.
Пастки:
- власні колбеки не варто типізувати як події DOM:
onSelect: (event) => ...з передачею всього об'єкта події змушує батька знати про DOM. Краще передавати значення:onSelect(item.id); anyдля події вимикає перевірку доступу до полів - друкарська помилкаevent.target.vauleпройде непоміченою.
TypeScript виводить тип стану з початкового значення, і найчастіше явний тип не потрібен:
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [open, setOpen] = useState(false); // boolean
Коли виведення не спрацьовує:
1. Початкове null - значення з'явиться пізніше:
type User = { id: number; name: string };
const [user, setUser] = useState<User | null>(null);
if (user) {
user.name; // після перевірки - User
}
Без явного типу useState(null) дасть тип null, і setUser(data) не скомпілюється.
2. Порожній масив:
const [items, setItems] = useState<Product[]>([]);
Без параметра useState([]) дає never[] - у такий масив нічого не додати.
3. Обмежений набір значень (union):
type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');
Без явного типу стан стане string, і setStatus('sucess') з помилкою пройде.
Стани, що залежать один від одного, краще поєднати в один з дискримінованим union - тоді неможливі комбінації не скомпілюються:
type RequestState =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
const [state, setState] = useState<RequestState>({ status: 'loading' });
if (state.status === 'success') {
state.data; // доступно лише тут
}
Окремі isLoading, error, data дозволяють стан «завантажується, але з помилкою й даними», а union - ні.
Функція оновлення типізується автоматично: setCount((c) => c + 1) знає, що c - число.
Лінива ініціалізація теж виводить тип з результату: useState(() => readFromStorage()).
Пастка: useState<User>({} as User) - «обіцянка» компілятору, що порожній об'єкт - повноцінний User. Помилки звернення до відсутніх полів з'являться під час виконання, а не компіляції. Чесніше User | null.
<StrictMode> - обгортка, яка в режимі розробки вмикає додаткові перевірки. У продакшен-збірці вона нічого не робить і нічого не коштує.
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
Шаблони Vite і більшості фреймворків вмикають її за замовчуванням.
Що вона робить:
1. Рендерить компоненти двічі. Функція компонента, ініціалізатори useState, функції useMemo та оновлювачі стану викликаються по два рази. Якщо результат різний - компонент нечистий: змінює щось зовнішнє під час рендеру.
function List({ items }) {
items.push({ id: 'extra' }); // мутація props - у StrictMode побачите два 'extra'
return items.map(...);
}
2. Запускає ефекти двічі при монтуванні: setup → cleanup → setup. Так React перевіряє, що ефект правильно прибирає за собою:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // без цього - два з'єднання
}, [roomId]);
Якщо ефект ламається від повторного запуску, він зламається й у реальному житті - при поверненні на сторінку, швидкому оновленні (Fast Refresh) чи в майбутніх можливостях React, що зберігають стан при прихованні компонентів.
3. Перевіряє колбеки ref так само: прив'язка - відв'язка - прив'язка.
4. Попереджає про застарілі API.
Типові «симптоми» в розробці:
console.logу компоненті друкує двічі (React DevTools можуть приглушувати друге виведення);- запит у
useEffectйде двічі - це нормально в розробці. Якщо він не скасовується й дає дублікати даних (два однакові записи черезPOSTв ефекті), проблема в коді, а не в StrictMode; - лічильник, збільшений в ефекті без очищення, показує 2.
Чого НЕ робити: вимикати StrictMode, щоб «прибрати подвійний запит». Правильне рішення - очищення в ефекті, скасування запиту (AbortController) або завантаження даних засобами фреймворку чи бібліотекою на кшталт TanStack Query.
Додатково: React Compiler і правила ESLint eslint-plugin-react-hooks знаходять ті самі порушення чистоти ще до запуску.
useRef має два різні призначення, і з TypeScript це видно в типах.
1. Посилання на DOM-елемент:
const inputRef = useRef<HTMLInputElement>(null);
// тип: RefObject<HTMLInputElement | null>
useEffect(() => {
inputRef.current?.focus(); // до монтування - null, тому ?.
}, []);
return <input ref={inputRef} />;
Тип елемента - відповідний клас DOM: HTMLInputElement, HTMLDivElement, HTMLCanvasElement. Якщо вказати не той (HTMLDivElement для <input>), TypeScript повідомить про помилку в атрибуті ref.
2. Змінне значення, що не викликає рендер:
const timerId = useRef<number | null>(null);
const renders = useRef(0); // RefObject<number>, тип виведено
function start() {
timerId.current = window.setInterval(tick, 1000);
}
Що змінилося в типах React 19:
useRefвимагає аргумент.useRef<number>()без початкового значення - помилка компіляції; треба явноuseRef<number | undefined>(undefined);currentзавжди можна змінювати. РанішеuseRef<T>(null)повертавRefObjectзcurrentлише для читання, аuseRef<T>(initial)-MutableRefObject, і плутанина між ними давала незрозумілі помилки. Тепер є одинRefObject<T>зі зміннимcurrent;MutableRefObjectоголошено застарілим.
window.setInterval, а не setInterval: у проєктах, де є і типи DOM, і типи Node.js, глобальний setInterval повертає NodeJS.Timeout, а не число. Явний window. прибирає конфлікт, або тип ref - ReturnType<typeof setInterval>.
Колбек-ref з очищенням (React 19):
<div ref={(node) => {
if (!node) return;
const observer = new ResizeObserver(onResize);
observer.observe(node);
return () => observer.disconnect(); // функція очищення
}} />
У TypeScript колбек-ref тепер не може неявно повертати значення: ref={(node) => (instance = node)} - помилка, бо присвоєння повертає значення, яке React вважав би функцією очищення. Потрібні фігурні дужки.
Правило з документації: не читати й не змінювати ref.current під час рендеру (крім лінивої ініціалізації) - лише в обробниках подій та ефектах.
До React 19 функціональний компонент не отримував ref як prop - для цього обгортали компонент у forwardRef:
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return <input ref={ref} {...props} />;
});
У React 19 ref - звичайний prop:
import type { ComponentProps } from 'react';
type InputProps = ComponentProps<'input'> & {
label: string;
};
function Input({ label, ref, ...props }: InputProps) {
return (
<label>
{label}
<input ref={ref} {...props} />
</label>
);
}
const emailRef = useRef<HTMLInputElement>(null);
<Input label="Email" type="email" ref={emailRef} required />;
forwardRef ще працює, але документація позначає його як непотрібний для нового коду, і в майбутніх версіях його оголосять застарілим.
ComponentProps<'input'> - усі атрибути нативного <input>, включно з ref, обробниками подій, aria-* і data-*. Обгортка автоматично приймає все, що приймає нативний елемент, без ручного перелічення.
Варіанти утилітних типів:
ComponentProps<'button'>- атрибути разом зref(у React 19 для нативних елементів);ComponentPropsWithoutRef<'button'>- те саме безref, коли обгортка свідомо не передає ref далі;ComponentProps<typeof Button>- props іншого компонента. Корисно, щоб розширити чужий компонент чи переиспользувати його типи без експорту.
Перевизначення атрибута: якщо власний prop збігається з нативним, але має інший тип, нативний треба прибрати:
type SelectProps = Omit<ComponentProps<'select'>, 'onChange'> & {
onChange: (value: string) => void;
};
Без Omit типи перетнуться, і TypeScript вимагатиме обробник, сумісний з обома сигнатурами одночасно.
Пастки:
- розгорнути
...propsдо свогоclassName- і переданий ззовні клас перезапише внутрішній. Порядок і злиття класів (clsx,tailwind-merge) треба продумати; - бібліотеки, що підтримують React 18, досі використовують
forwardRef, і це нормально - для них потрібна сумісність з обома версіями.
useReducer найкраще поєднується з дискримінованим union дій: поле type визначає, які ще поля має дія.
type CartItem = { id: number; title: string; price: number; qty: number };
type CartState = {
items: CartItem[];
coupon: string | null;
};
type CartAction =
| { type: 'added'; item: CartItem }
| { type: 'removed'; id: number }
| { type: 'quantityChanged'; id: number; qty: number }
| { type: 'couponApplied'; code: string }
| { type: 'cleared' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'added':
return { ...state, items: [...state.items, action.item] };
case 'removed':
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case 'quantityChanged':
return {
...state,
items: state.items.map((i) => (i.id === action.id ? { ...i, qty: action.qty } : i)),
};
case 'couponApplied':
return { ...state, coupon: action.code };
case 'cleared':
return { items: [], coupon: null };
default: {
const unreachable: never = action;
return state;
}
}
}
const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
Що дає типізація:
- у кожній гілці
switchTypeScript звужує тип: уcase 'removed'доступнеaction.id, аaction.item- помилка; dispatchперевіряє дії:dispatch({ type: 'removed' })безidчи з неіснуючимtypeне скомпілюється;- перевірка повноти: присвоєння
neverуdefaultдає помилку компіляції, якщо додати новий тип дії й забути обробити його в редьюсері.
Тип стану й дій виводиться з функції-редьюсера, тож параметри useReducer вказувати явно не треба. Явна анотація повернення CartState у редьюсері ловить випадки, коли гілка повертає щось не те.
Назви дій - у минулому часі («що сталося»: added, couponApplied), як радить документація React, а не команди (ADD_ITEM).
Лінива ініціалізація - третій аргумент: useReducer(cartReducer, userId, createInitialCart), де createInitialCart(userId) повертає початковий стан. Тип аргументу перевіряється.
Пастка: мутувати стан у редьюсері (state.items.push(...)) TypeScript не заборонить - для гарантій незмінності типи можна оголосити як readonly (readonly CartItem[]), або використати Immer (useImmerReducer).
createContext потребує значення за замовчуванням. Для контексту з реальними даними (поточний користувач, кошик) осмисленого значення за замовчуванням немає, тому часто пишуть null:
type AuthContextValue = {
user: User;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
Тепер кожен useContext(AuthContext) повертає AuthContextValue | null, і в кожному компоненті потрібна перевірка. Набридливо - і перевірка однаково нічого не робить, бо провайдер завжди є.
Рішення - власний хук з перевіркою в одному місці:
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth треба викликати всередині <AuthProvider>');
}
return context;
}
function Header() {
const { user, logout } = useAuth(); // AuthContextValue без null
return <button onClick={logout}>Вийти, {user.name}</button>;
}
Переваги:
- тип без
nullу компонентах; - зрозуміла помилка одразу, якщо компонент випадково відрендерили поза провайдером (замість
Cannot read properties of nullдесь далі); - контекст можна не експортувати - лише хук і провайдер. Так менше способів використати його неправильно.
Провайдер разом із логікою:
export function AuthProvider({ user, children }: { user: User; children: ReactNode }) {
const logout = useCallback(() => router.post('/logout'), []);
const value = useMemo(() => ({ user, logout }), [user, logout]);
return <AuthContext value={value}>{children}</AuthContext>;
}
У React 19 контекст можна рендерити напряму як провайдер - <AuthContext value={...}> замість <AuthContext.Provider value={...}>.
Альтернатива, коли розумне значення за замовчуванням є (тема, мова): createContext<Theme>('light') - перевірки на null не потрібні взагалі.
Пастка: createContext<AuthContextValue>(null!) чи {} as AuthContextValue - «заглушка» для компілятора. TypeScript замовкне, але компонент поза провайдером отримає null чи порожній об'єкт і впаде з незрозумілою помилкою під час виконання.
Компоненти на кшталт списку, таблиці чи випадного списку працюють з даними будь-якого типу. Без узагальнень доводиться писати any - і зв'язок між даними та колбеками губиться.
Узагальнений компонент:
type ListProps<T> = {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => ReactNode;
onSelect?: (item: T) => void;
};
function List<T>({ items, getKey, renderItem, onSelect }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={getKey(item)} onClick={() => onSelect?.(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
<List
items={users} // T = User - виведено з items
getKey={(user) => user.id}
renderItem={(user) => user.name} // user: User
onSelect={(user) => open(user.id)}
/>
TypeScript виводить T з переданих даних, і всі колбеки отримують правильний тип. Звернення до неіснуючого поля - помилка компіляції.
Обмеження типу параметра:
function Table<T extends { id: number }>({ rows, columns }: TableProps<T>) { /* ... */ }
Тепер можна використовувати row.id всередині й не передавати getKey.
Колонки, прив'язані до полів даних:
type Column<T> = {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => ReactNode;
};
key: keyof T дозволяє лише існуючі поля - перейменування поля в моделі одразу підсвітить усі таблиці, де воно використовується.
Синтаксис у .tsx: стрілкова функція const List = <T,>(props: ListProps<T>) => ... потребує коми після T - інакше <T> прочитається як JSX-тег. Оголошення function List<T>(...) цієї проблеми не має.
Явний параметр можна передати в JSX, якщо виведення неможливе: <List<User> items={[]} ... />.
Пастки:
memoчиforwardRefгубить узагальнення:memo(List)повертає компонент зT = unknown. Рішення - приведення типу (memo(List) as typeof List) або відмова від обгортки (з React Compiler потреба в ручномуmemoзменшується);- надто складні узагальнення (умовні типи, кілька параметрів) погіршують повідомлення про помилки - для команди простіший тип часто корисніший за максимально точний.
Звичайні необов'язкові props дозволяють будь-які комбінації, навіть безглузді:
type ButtonProps = {
href?: string; // посилання
onClick?: () => void; // кнопка
external?: boolean; // має сенс лише з href
};
<Button onClick={save} external /> // компілюється, хоча безглуздо
<Button /> // ні дії, ні посилання
Дискримінований union описує кожен допустимий варіант окремо:
type LinkProps = { as: 'link'; href: string; external?: boolean };
type ActionProps = { as: 'button'; onClick: () => void; disabled?: boolean };
type ButtonProps = (LinkProps | ActionProps) & { children: ReactNode };
function Button(props: ButtonProps) {
if (props.as === 'link') {
return (
<a href={props.href} target={props.external ? '_blank' : undefined}>
{props.children}
</a>
);
}
return (
<button onClick={props.onClick} disabled={props.disabled}>
{props.children}
</button>
);
}
<Button as="link" href="/jobs">Вакансії</Button>
<Button as="button" onClick={save}>Зберегти</Button>
<Button as="button" href="/x">...</Button> // помилка: href не існує для button
Варіант без явного дискримінатора - через never, щоб props взаємно виключали один одного:
type Props =
| { value: string; defaultValue?: never; onChange: (v: string) => void } // керований
| { defaultValue?: string; value?: never; onChange?: never }; // некерований
Передати одночасно value і defaultValue неможливо - типова помилка з полями форм.
Типові застосування:
- керований / некерований компонент;
- компонент стану запиту:
{ status: 'loading' } | { status: 'error'; error: string } | { status: 'success'; data: T }; - модальне вікно з різними наборами кнопок;
- поле форми, де
optionsобов'язкові лише для типуselect.
Пастки:
- деструктуризація в параметрах губить звуження:
function Button({ as, href, onClick })-hrefне існує у варіантіbutton, TypeScript не дасть деструктуризувати. Звужувати треба поprops.as, а деструктуризувати вже всередині гілки; ...restз union дає перетин полів і може «загубити» варіант - передаючи props далі, краще явно перелічувати;- повідомлення про помилки для великих union стають довгими - варто тримати варіантів небагато й давати їм імена.
Докладніше в документації: Звуження типів: дискриміновані union
Створення проєкту:
npm create vite@latest my-app -- --template react-ts
Шаблон містить vite.config.ts з плагіном @vitejs/plugin-react, tsconfig для коду застосунку й окремо для конфігурації, ESLint з eslint-plugin-react-hooks.
Що робить @vitejs/plugin-react:
- перетворює JSX (автоматичний runtime - імпорт
Reactу файлах не потрібен); - Fast Refresh - зміна компонента оновлює лише його, зі збереженням стану. Працює, якщо файл експортує лише компоненти: експорт констант чи функцій поряд з компонентом змушує перезавантажувати модуль повністю;
- у Vite 8 перетворення робить швидкий компілятор на Rust (Oxc), Babel за замовчуванням не потрібен.
React Compiler у поточній версії плагіна підключається двома способами:
// 1. через Babel - стабільний варіант
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});
// 2. нативний порт на Rust - експериментальний
export default defineConfig({
plugins: [react({ compiler: true })],
});
Компілятор 1.0 стабільний, працює з React 17+ і додає мемоізацію на етапі збирання. Для поступового впровадження - compilationMode: 'annotation' (компілюються лише функції з директивою "use memo").
TypeScript важливо розуміти правильно:
- Vite не перевіряє типи - він лише видаляє їх під час перетворення. Збирання пройде навіть з помилками типів;
- перевірка - окремим кроком:
tsc -bу скриптіbuild(так у шаблоні) і в CI, а під час розробки - редактор чиvite-plugin-checker; "jsx": "react-jsx","moduleResolution": "bundler","strict": true,"noUncheckedIndexedAccess"- корисні налаштування для нового проєкту;"isolatedModules": trueпотрібен, бо кожен файл перетворюється окремо:export typeдля реекспорту типів.
Змінні оточення - лише з префіксом VITE_, через import.meta.env; типи - в vite-env.d.ts.
ESLint з eslint-plugin-react-hooks (конфігурація recommended) у версії 7 містить і правила хуків, і правила React Compiler - порушення, через які компілятор пропустив би компонент, видно ще в редакторі.
З Laravel - laravel-vite-plugin поряд з плагіном React, сторінки через Inertia; стартовий набір Laravel React вже містить це налаштування.
eslint-plugin-react-hooks - офіційний плагін ESLint від команди React. З версії 6-7 він містить не лише два класичні правила хуків, а й правила, побудовані на аналізі React Compiler.
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
export default defineConfig([
reactHooks.configs.flat.recommended,
]);
Класичні правила:
rules-of-hooks- хуки лише на верхньому рівні компонентів і власних хуків, не в умовах і циклах;exhaustive-deps- усі значення, використані вuseEffect/useMemo/useCallback, мають бути в залежностях.
Правила компілятора (у конфігурації recommended) ловлять порушення правил React, через які компонент працюватиме неправильно або компілятор його пропустить:
purity- нечисті виклики під час рендеру (Math.random(),Date.now()у тілі компонента);immutability- мутація props, стану чи значень з хуків;refs- читання чи записref.currentпід час рендеру;set-state-in-renderіset-state-in-effect- викликsetStateпрямо в рендері (нескінченний цикл) чи синхронно в ефекті (зайвий рендер; часто стан можна обчислити під час рендеру);static-components- оголошення компонента всередині іншого компонента (він перестворюється щоразу й губить стан);globals- зміна глобальних змінних під час рендеру;preserve-manual-memoization- ручнийuseMemo/useCallback, який компілятор не може зберегти;incompatible-library- бібліотеки з API, несумісним з мемоізацією компілятора;error-boundaries,use-memo,unsupported-syntax,config,gating.
Конфігурація recommended-latest додає експериментальні правила.
Чому це важливо навіть без компілятора: кожне з цих порушень - справжній баг або джерело зайвих рендерів незалежно від того, чи увімкнено компілятор. А з компілятором ESLint показує, які компоненти він пропустить і чому, - ще в редакторі.
Як упроваджувати в старий проєкт:
- увімкнути правила й подивитися кількість порушень - це оцінка, наскільки код готовий до компілятора;
- виправляти поступово, починаючи з
purity,immutability,refs- вони вказують на реальні помилки; - не вимикати правило коментарем без пояснення:
eslint-disableдляexhaustive-deps- найчастіше маскування справжньої проблеми із застарілими значеннями (stale closure).