React и TypeScript

читать 11 мин
TypeScriptReactTypes

Sample Image

Введение

TypeScript стал стандартом де-факто для React-проектов. Строгая типизация помогает ловить ошибки на этапе компиляции, делает код самодокументирующимся и значительно улучшает Developer Experience благодаря автокомплиту и навигации по коду.

В этой статье мы разберём все ключевые аспекты типизации в React от базовых пропсов до продвинутых дженерик-компонентов и полиморфных паттернов.

Основы типизации в React

Типизация пропсов

Пропсы - фундамент типизации React-компонентов. Используйте type или interface:

type ButtonProps = {
  label: string;
  variant?: 'primary' | 'secondary';  // union literal
  disabled?: boolean;
  onClick: () => void;
};
 
const Button = ({ label, variant = 'primary', onClick }: ButtonProps) => (
  <button className={variant} onClick={onClick}>{label}</button>
);

Разница между interface и type: на практике для пропсов они взаимозаменяемы. interface удобнее, если нужно расширение через extends, а type — для объединений (|) и пересечений (&). Большинство команд выбирают один подход и придерживаются его.

Дженерики - для универсальных компонентов:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
}

function List<T>({ items, renderItem }: ListProps<T>) {
  return <ul>{items.map(renderItem)}</ul>;
}

Дискриминированные объединения - когда набор пропсов зависит от варианта:

type AlertProps =
  | { variant: "simple"; message: string }
  | { variant: "withAction"; message: string; onAction: () => void };

Типы children

React предоставляет несколько типов для children, каждый с разной степенью строгости:

ТипПринимаетКогда использовать
React.ReactNodeСтроки, числа, элементы, null, массивыВ большинстве случаев
React.ReactElementТолько JSX-элементыСтрогий контроль
(data: T) => ReactNodeФункция (render props)Паттерн render props

События

React предоставляет типизированные обработчики для всех DOM-событий:

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};
 
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
};
 
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { };

Шаблон: React.[EventType]Event<HTML[Element]Element> - универсальный формат для событий.

Типизация хуков

useState

TypeScript автоматически выводит тип из начального значения. Указывайте тип явно, когда начальное значение не раскрывает полный тип:

const [count, setCount] = useState(0);              // number
const [user, setUser] = useState<User | null>(null); // явный тип

useRef

Два основных сценария: DOM-ссылки и мутабельные значения:

const inputRef = useRef<HTMLInputElement>(null);  // DOM
const timerRef = useRef<number>(0);               // мутабельное значение

useReducer

Discriminated union для actions - ключевой паттерн:

type State = { count: number; loading: boolean };
type Action =
  | { type: 'increment' }
  | { type: 'set'; payload: number };
 
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + 1 };
    case 'set': return { ...state, count: action.payload };
  }
};

useMemo и useCallback

Типы выводятся автоматически. Указывайте тип аргументов callback:

const doubled = useMemo(() => count * 2, [count]);
const handleClick = useCallback((id: string) => { /* ... */ }, []);

Кастомные хуки

Самое интересное - здесь типизация делает API хука понятным:

// Простой хук
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle] as const;
  //                       ^^^^^^^^ без as const тип будет (boolean | () => void)[]
  //                                с ним — readonly [boolean, () => void]
}

// Хук с дженериком
function useLocalStorage<T>(key: string, fallback: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? (JSON.parse(stored) as T) : fallback;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

// Использование — тип выводится из fallback
const [theme, setTheme] = useLocalStorage("theme", "dark");
//     ^string  ^(value: string) => void

Типобезопасный Context

Главная проблема Context в TypeScript - начальное значение. Есть два подхода, но рекомендуется первый:

Подход А: undefined + кастомный хук (рекомендуемый)

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
 
const useTheme = () => {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx; // тип без undefined
};

Универсальная фабрика контекстов

Избавляет от повторяющегося boilerplate:

function createStrictContext<T>(name: string) {
  const Context = createContext<T | undefined>(undefined);
  const useStrictContext = (): T => {
    const ctx = useContext(Context);
    if (ctx === undefined)
      throw new Error(`use${name} must be used within ${name}Provider`);
    return ctx;
  };
  return [Context.Provider, useStrictContext] as const;
}
 
// Использование
const [AuthProvider, useAuth] = createStrictContext<AuthContext>('Auth');

Важно: Никогда не используйте {} as ContextType - это скрывает ошибки runtime.

Дженерик-компоненты

Дженерики позволяют создавать переиспользуемые компоненты с выводом типов:

type SelectProps<T> = {
  items: T[];
  selected: T;
  getLabel: (item: T) => string;
  onChange: (item: T) => void;
};
 
const Select = <T,>({ items, selected, getLabel, onChange }: SelectProps<T>) => (
  <select value={String(selected)}
    onChange={(e) => onChange(items[e.target.selectedIndex])}
  >
    {items.map((item, i) => (
      <option key={i}>{getLabel(item)}</option>
    ))}
  </select>
);

При использовании TypeScript сам выведет тип T:

<Select
  items={[{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]}
  selected={{ id: 1, name: 'Alice' }}
  getLabel={(u) => u.name}   // u типизирован автоматически
  onChange={(u) => console.log(u.id)}
/>

Запятая после T: Синтаксис <T,> нужен в .tsx файлах, чтобы парсер отличал дженерик от JSX-тега.

Ограничение дженерика через extends.

Когда компонент должен работать не с любым типом, а только с теми, у которых есть определённые поля:

interface HasId {
  id: string | number;
}

interface DataListProps<T extends HasId> {
  items: T[];
  onSelect: (item: T) => void;
  renderItem: (item: T) => React.ReactNode;
}

function DataList<T extends HasId>({ items, onSelect, renderItem }: DataListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onSelect(item)}>
          {renderItem(item)}
        </li>
      ))}
    </ul>
  );
}

// Работает — у User есть id
interface User { id: number; name: string; }
<DataList
  items={users}
  onSelect={(user) => console.log(user.name)} // user типизирован как User
  renderItem={(user) => <span>{user.name}</span>}
/>

// Ошибка компиляции — у { title: string } нет id
<DataList items={[{ title: "oops" }]} ... />

Дженерик + forwardRef

forwardRef усложняет синтаксис, потому что не поддерживает дженерики напрямую. Обходной путь:

// Способ 1: приведение типа через as
const GenericList = forwardRef(function GenericList<T>(
  props: ListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return <ul ref={ref}>{/* ... */}</ul>;
}) as <T>(props: ListProps<T> & { ref?: React.Ref<HTMLUListElement> }) => React.ReactElement;

// Способ 2 (проще): вынести ref в проп
interface ListProps<T> {
  items: T[];
  listRef?: React.Ref<HTMLUListElement>;
}

function List<T>({ items, listRef }: ListProps<T>) {
  return <ul ref={listRef}>{/* ... */}</ul>;
}

Дженерик-компоненты оправданы, когда один и тот же UI-паттерн применяется к разным типам данных: списки, таблицы, селекты, формы, модалки с разным содержимым. Если компонент работает только с одним типом - дженерик излишен, обычный интерфейс пропсов будет проще и понятнее.

Почему стоит отказаться от React.FC

Тип React.FC (он же React.FunctionComponent) долгое время был стандартным способом типизации компонентов. Однако сообщество React пришло к консенсусу: использовать его не стоит.

Вот основные причины:

Проблемы React.FC • Неявное добавление children (до React 18). Любой компонент принимал children, даже если не должен был, что скрывало баги. • Невозможность использовать дженерики. Синтаксис React.FC не позволяет параметризовать компонент типом T. • Некорректная работа с defaultProps. TypeScript не мог вывести типы из defaultProps при использовании React.FC. • Избыточный шум в коде. React.FC не даёт ничего полезного, лишь удлиняет определение компонента. • Ограничение возвращаемого типа. React.FC форсирует ReactElement | null, хотя компонент может возвращать string, number и т.д.

Что использовать вместо React.FC Типизируйте пропсы напрямую через аргумент функции:

// ❌ Было
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>;
};
 
// ✅ Стало
const Button = ({ label, onClick }: ButtonProps) => {
  return <button onClick={onClick}>{label}</button>;
};

Если компоненту нужен children, укажите его явно в типе пропсов:

type CardProps = {
  title: string;
  children: React.ReactNode;
};
 
const Card = ({ title, children }: CardProps) => (
  <div><h2>{title}</h2>{children}</div>
);

Совет: Явное указание children делает API компонента прозрачным и предотвращает случайную передачу дочерних элементов туда, где их не ожидают.

Расширение HTML-элементов

Для компонентов-обёрток над нативными элементами используйте ComponentProps:

type InputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size'> & {
  label: string;
  error?: string;
  size?: 'sm' | 'md' | 'lg';
};
 
const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, size = 'md', className, ...rest }, ref) => (
    <div>
      <label>{label}</label>
      <input ref={ref} className={`input-${size} ${className ?? ''}`} {...rest} />
      {error && <span className="error">{error}</span>}
    </div>
  ),
);
УтилитаОписание
ComponentPropsWithRef<'div'>Все пропсы div + ref
ComponentPropsWithoutRef<'div'>Все пропсы div без ref
ComponentProps<typeof MyComp>Извлечь пропсы компонента

Полиморфные компоненты (as-prop)

Паттерн, при котором компонент может рендериться как любой HTML-элемент, с автоматической подстройкой допустимых пропсов:

type BoxProps<T extends React.ElementType> = {
  as?: T;
  children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'children'>;
 
const Box = <T extends React.ElementType = 'div'>({
  as, children, ...rest
}: BoxProps<T>) => {
  const Component = as || 'div';
  return <Component {...rest}>{children}</Component>;
};
 
// Типы подстраиваются автоматически:
<Box as="a" href="/home">Link</Box>       // href доступен
<Box as="button" onClick={() => {}}>Btn</Box> // onClick доступен

Best practice

Строгий tsconfig

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Ключевая опция: noUncheckedIndexedAccess делает arr[0] типом T | undefined вместо T - предотвращает runtime-ошибки.

as const вместо enum

// ❌ enum — проблемы с tree-shaking, лишний runtime-код
enum Status { Idle = 'idle', Loading = 'loading' }
 
// ✅ as const + вывод типа
const STATUS = {
  Idle: 'idle',
  Loading: 'loading',
  Error: 'error',
} as const;
 
type Status = (typeof STATUS)[keyof typeof STATUS];
// 'idle' | 'loading' | 'error'

Discriminated Unions для состояний

Вместо набора boolean-флагов описывайте каждое состояние как отдельный вариант union:

// ❌ Плохо — невозможные комбинации допустимы
type BadState = {
  loading: boolean;
  error: string | null;
  data: User[] | null;
};
 
// ✅ Хорошо — каждое состояние точно описано
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: T };

Exhaustive check

Гарантирует, что при добавлении нового варианта в union компилятор укажет на все необработанные места:

function assertNever(x: never): never {
  throw new Error(`Unexpected value: ${x}`);
}
 
const getLabel = (status: Status): string => {
  switch (status) {
    case 'idle': return 'Ready';
    case 'loading': return 'Loading...';
    case 'error': return 'Failed';
    default: return assertNever(status); // ошибка если забыли кейс
  }
};

Template Literal Types для API

Мощный паттерн для типизации HTTP-клиентов:

type ApiEndpoints = {
  'GET /users': { response: User[] };
  'POST /users': { response: User; body: CreateUserDto };
  'DELETE /users/:id': { response: void; params: { id: string } };
};
 
async function api<E extends keyof ApiEndpoints>(
  endpoint: E,
  ...args: ApiEndpoints[E] extends { body: infer B } ? [body: B] : []
): Promise<ApiEndpoints[E]['response']> { /* ... */ }
 
const users = await api('GET /users');  // User[]

unknown вместо any

// ❌ any отключает проверки — ошибки утекают дальше
function parse(input: any) {
  return input.name.toUpperCase(); // никаких ошибок, падение в runtime
}

// ✅ unknown заставляет проверить перед использованием
function parse(input: unknown) {
  if (typeof input === "object" && input !== null && "name" in input) {
    return (input as { name: string }).name.toUpperCase();
  }
  throw new Error("Invalid input");
}

satisfies - проверка без потери литерального типа

// ❌ аннотация расширяет тип
const routes: Record<string, { path: string }> = {
  home: { path: "/" },
  about: { path: "/about" },
};
routes.typo; // нет ошибки — ключ string

// ✅ satisfies проверяет структуру, но сохраняет точные ключи
const routes = {
  home: { path: "/" },
  about: { path: "/about" },
} satisfies Record<string, { path: string }>;

routes.typo; // ошибка компиляции
routes.home.path; // тип "/"

Расширение HTML-элементов через ComponentPropsWithoutRef

// ❌ Вручную дублировать все пропсы button
interface ButtonProps {
  variant: "primary" | "secondary";
  disabled?: boolean;
  onClick?: () => void;
  // ... ещё 40 пропсов?
}

// ✅ Наследовать от HTML и добавить своё
type ButtonProps = {
  variant: "primary" | "secondary";
} & React.ComponentPropsWithoutRef<"button">;

function Button({ variant, className, ...rest }: ButtonProps) {
  return <button className={`btn-${variant} ${className ?? ""}`} {...rest} />;
}

Шпаргалка: Do & Don't

✅ Делайте❌ Избегайте
strict: true в tsconfigОтключать strict
as const объектыenum
Discriminated unions для состояний{ loading: boolean; data: T | null }
Zod / Valibot для runtime-валидацииСлепо доверять as T от API
type для пропсовReact.FC
ComponentPropsWithoutRef<'div'>Руками перечислять нативные пропсы
satisfies для проверки без потери типаas кастинг без необходимости
assertNever в switchИгнорировать default
noUncheckedIndexedAccessarr[0] без проверки на undefined

Заключение

Типизация - это инструмент проектирования. Когда вы описываете тип, вы формулируете контракт: что компонент принимает, что возвращает, какие состояния возможны. Это заставляет продумывать архитектуру до написания логики, а не после.

Хорошие типы ловят ошибки до запуска, помогают IDE подсказывать и документируют контракты компонентов без лишних комментариев. Начните со строгого tsconfig, описывайте состояния через union, валидируйте внешние данные на границах - и доверяйте выводу типов там, где TypeScript справляется сам.