React и TypeScript

Введение
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 |
noUncheckedIndexedAccess | arr[0] без проверки на undefined |
Заключение
Типизация - это инструмент проектирования. Когда вы описываете тип, вы формулируете контракт: что компонент принимает, что возвращает, какие состояния возможны. Это заставляет продумывать архитектуру до написания логики, а не после.
Хорошие типы ловят ошибки до запуска, помогают IDE подсказывать и документируют контракты компонентов без лишних комментариев. Начните со строгого tsconfig, описывайте состояния через union, валидируйте внешние данные на границах - и доверяйте выводу типов там, где TypeScript справляется сам.