Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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 під час рендеру (крім лінивої ініціалізації) - лише в обробниках подій та ефектах.

Докладніше в документації: useRef

До 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, і це нормально - для них потрібна сумісність з обома версіями.

Докладніше в документації: 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 });

Що дає типізація:

  • у кожній гілці switch TypeScript звужує тип: у 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).

Докладніше в документації: TypeScript у React: useReducer

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 чи порожній об'єкт і впаде з незрозумілою помилкою під час виконання.

Докладніше в документації: TypeScript у React: useContext