Middle: питання на співбесіді з теми «TypeScript та інструменти»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
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 чи порожній об'єкт і впаде з незрозумілою помилкою під час виконання.