Пропсы описывают через interface или псевдоним type и аннотируют ими параметр функции; дальше TypeScript проверяет каждое место, где используется компонент. React.FC не нужен: обычная функция с типизированными пропсами считается общепринятой практикой.
**interface или type**: для пропсов подходят оба. interface можно расширять через extends; псевдоним type нужен для объединений, например для пропсов-вариантов.
Необязательные пропсы помечают ?, а значения по умолчанию задают в деструктуризации (variant = 'primary'). В React 19 defaultProps для функциональных компонентов удалили.
Объединения литералов ('primary' | 'secondary') вместо string превращают опечатку в ошибку компиляции.
**Почему не React.FC**: он ничего не добавляет к обычной аннотации и не позволяет описать дженерик-компонент. С типов React 18 он больше не добавляет неявный children, поэтому старый код, который на это опирался, ломается.
ReactMiddle
TypeScript в React
Типизация props, children и событий, дженерик-компоненты, discriminated unions, утилитные типы, типизация хуков и контекста, strict
Закрыто вопросов
0 / 21
- 0Повторить
- 8Часто спрашивают
Вопросы
children типизируют как React.ReactNode, когда компонент принимает всё, что React умеет отрисовать: строки, числа, элементы, массивы, фрагменты, null, undefined и булевы значения. ReactElement и JSX.Element принимают только элемент, но не текст и не null.
**ReactNode**: правильный выбор по умолчанию для children и пропсов вроде icon или footer.
**ReactElement**: то, во что вычисляется JSX (<Icon />); нужен, только когда элемент надо клонировать или проверить, а это редкость.
**JSX.Element**: то же, что ReactElement<any, any>. В типах React 19 глобального пространства имён JSX больше нет, пишите React.JSX.Element.
**PropsWithChildren<P>** добавляет children?: ReactNode к существующим пропсам.
Render prop в виде children: типизируйте как функцию, children: (item: T) => ReactNode.
Инлайн-обработчикам аннотация не нужна: TypeScript выводит тип события из JSX-атрибута. Для обработчика, объявленного отдельно, используют типы событий React с элементом в параметре, например React.ChangeEvent<HTMLInputElement> или React.SubmitEvent<HTMLFormElement>.
Частые типы: ChangeEvent для инпутов, select и textarea; SubmitEvent для onSubmit (актуальные @types/react объявили устаревшим прежний FormEvent); MouseEvent для кликов; KeyboardEvent для клавиш; FocusEvent для фокуса и blur.
Типы обработчиков: React.ChangeEventHandler<HTMLInputElement> типизирует сразу всю функцию.
**currentTarget и target**: currentTarget указывает на элемент, к которому привязан обработчик, и типизирован им; target указывает, откуда событие началось, и для большинства событий это просто EventTarget.
Это синтетические события React, а не DOM-Event; исходное DOM-событие лежит в e.nativeEvent.
Тип передают явно через дженерик: useState<User | null>(null), useState<string[]>([]). Без него TypeScript выводит тип из начального значения, поэтому null разрешит только null, а [] превратится в never[].
Вывода типа хватает для примитивов и полных объектов: useState(0) даёт number, useState('') даёт string.
Объединение литералов для полей статуса: useState<'idle' | 'loading' | 'error'>('idle').
Необязательное значение: useState<string>() даёт string | undefined.
Не обманывайте компилятор через useState<User>({} as User): это компилируется и падает, когда поле читают до прихода данных.
Сеттер типизируется сам: setUser принимает User, null или функцию-апдейтер.
Ref на DOM типизируют элементом и начальным null: у useRef<HTMLInputElement>(null) поле .current имеет тип HTMLInputElement | null, и TypeScript заставляет его проверить. В React 19 ref стал обычным пропсом: компонент объявляет его в пропсах, forwardRef не нужен.
Ref на DOM: current равен null до монтирования и после размонтирования; пишите ref.current?.focus().
Ref для значения: useRef(0) или useRef<number | null>(null) для id таймера; это изменяемая коробка, не привязанная к элементу.
Типы React 19: useRef всегда требует аргумент, а тип MutableRefObject объявлен устаревшим в пользу RefObject.
Как принять ref: React.ComponentProps<'input'> уже включает ref; или объявите ref?: React.Ref<HTMLInputElement> сами.
forwardRef по-прежнему работает, но больше не нужен, и его собираются объявить устаревшим.
Пропсы строят из React.ComponentProps<'button'> и пересекают со своими: тогда обёртка принимает каждый атрибут, событие и ref, которые принимает <button>, а остальное раскладывается на элемент. Omit нужен, когда ваш пропс заменяет нативный с другим типом.
**ComponentProps<'button'>** в React 19 включает ref; ComponentPropsWithoutRef нужен, когда пробрасывать его нельзя.
**Omit<..., 'size'>**: нативный size у <input> имеет тип number; уберите его, прежде чем объявлять свой size: 'sm' | 'lg', иначе пересечение станет never.
**ComponentProps<typeof Button> читает пропсы существующего компонента; удобно для обёртки над обёрткой или стороннего компонента, который не экспортирует тип пропсов.
Объединяйте className**, а не позволяйте значению вызывающего молча заменить ваше.
...rest последним позволяет вызывающему переопределить что угодно; ставьте его первым, если какие-то пропсы должны побеждать.
Размеченное объединение состоит из объектных типов с общим литеральным полем (дискриминантом, например status), поэтому проверка этого поля сужает значение до одного варианта. Так невозможные состояния становятся непредставимыми: data существует, только когда status равен 'success'.
Вместо флагов: isLoading, error? и data? допускают бессмысленные комбинации (загрузка с ошибкой и данными). Объединение допускает ровно те состояния, которые бывают.
Сужение: внутри case 'error': TypeScript знает, что state.error есть; вне этой ветки чтение state.error даёт ошибку компиляции.
Для пропсов тоже: { kind: 'link'; href: string } | { kind: 'button'; onClick: () => void } делает href обязательным для ссылок и запрещённым для кнопок.
Исчерпывающая проверка: когда каждая ветка возвращает значение, новый статус без обработки ломает сборку (noImplicitReturns или явная проверка через never).
Библиотеки используют ту же форму: результат TanStack Query сужает data, когда вы проверяете status === 'success'.
Не скомпилируется: const { kind, href } = props выдаст "Property 'href' does not exist on type 'ActionProps'". Деструктуризация читает href из всего объединения ещё до сужения, а у варианта button поля href нет.
TypeScript разрешает читать у объединения только свойства, которые есть у каждого варианта; kind общий, а href и onClick нет.
Исправление 1: сначала сузить, потом читать: if (props.kind === 'link') return <a href={props.href}>.
Исправление 2: деструктурировать внутри ветки, после проверки.
Исправление 3: добавить href?: never в вариант button. Тогда деструктуризация компилируется, а TypeScript (4.6+) сужает деструктурированный href до string после kind === 'link'.
То же правило объясняет, почему props.onClick в последней строке корректен: после того как ветка link вернула значение, props сужен до варианта button.
Компонент объявляют как дженерик-функцию, function List<T>(props: ListProps<T>), и используют T в пропсах. TypeScript выводит T из того, что вызывающий передал в items, поэтому колбэки вроде renderItem получают настоящий тип элемента без аннотаций.
Ограничения: T extends { id: string } позволяет компоненту читать item.id для ключей.
**Стрелочные функции в .tsx**: пишите <T,> (или <T extends unknown>), иначе парсер примет <T> за JSX-тег.
**memo теряет дженерик**: memo(List) возвращает не-дженерик компонент; приведите тип обратно через as typeof List или доверьте мемоизацию React Compiler.
С ref теперь просто: в React 19 ref стал пропсом, поэтому дженерик-компонент принимает его без старого обходного пути через forwardRef, который к тому же стирал дженерики.
Сделайте HOC дженериком по пропсам оборачиваемого компонента, P extends InjectedProps, а возвращаемому компоненту дайте тип Omit<P, keyof InjectedProps>. Тогда вызывающий передаёт только свои пропсы, а внутри HOC одно приведение типа возвращает внедрённые на место.
Вывод типов: withUser(Greeting) выводит P из Greeting, поэтому пропсы обёртки получаются из компонента, а не пишутся вручную.
Зачем приведение: Omit<P, 'user'> плюс user нельзя доказать равным P, ведь P может объявить более узкий user (например, AdminUser), и TypeScript сообщает, что P «could be instantiated with a different subtype of constraint». Приведение остаётся внутри HOC; места вызова по-прежнему проверяются.
**displayName**: собирайте его из имени оборачиваемого компонента, чтобы React DevTools показывали withUser(Greeting).
Refs: в React 19 ref стал обычным пропом и проходит через spread; в React 18 нужен был forwardRef.
Хуки снимают проблему: useCurrentUser() или render prop отдают значение напрямую, без Omit и без приведения; это одна из причин, почему хуки вытеснили большинство HOC.
Контекст создают как createContext<Value | null>(null) и отдают только через кастомный хук, который бросает ошибку, если провайдера нет. Тогда хук возвращает тип Value, каждый потребитель получает значение без null, а если провайдер забыли, видит понятную ошибку.
Почему не фиктивное значение по умолчанию: createContext({} as Value) компилируется, но компонент вне провайдера читает поля undefined и падает далеко от причины.
**Почему не !**: useContext(Ctx)! глушит компилятор в каждом потребителе вместо одной проверки.
Настоящее значение по умолчанию: если разумное значение существует (тема, локаль), передайте его в createContext и обойдитесь без null.
Экспортируйте хук, а не контекст: вызывающие не смогут обойти проверку, а тип контекста остаётся деталью реализации.
Экшены типизируют размеченным объединением по полю type, у редьюсера аннотируют state, action и тип результата, остальное useReducer выводит сам. Ветка default, которая присваивает action переменной типа never, не скомпилируется, если у нового экшена нет своего case.
Свой payload у каждого экшена: внутри case 'setStep': TypeScript знает, что action.step есть; dispatch({ type: 'setStep' }) без него даёт ошибку.
**Типизированный dispatch: вызывающие могут отправить только экшены из объединения и с правильным payload.
Не передавайте аргументы типа** в useReducer: в типах React 19 его дженерик-параметры изменились (useReducer<State, [Action]> вместо useReducer<Reducer<State, Action>>), а вывод из типизированного редьюсера работает в обоих вариантах.
Явный тип результата у редьюсера не даёт одной из веток молча вернуть неправильную форму.
Выводите оба типа из стора, RootState = ReturnType<typeof store.getState> и AppDispatch = typeof store.dispatch, затем один раз создайте типизированные хуки через useSelector.withTypes<RootState>() и useDispatch.withTypes<AppDispatch>() и используйте в компонентах только их.
Выводятся, а не пишутся: новый слайс расширяет RootState автоматически; написанный вручную интерфейс расходится с редьюсерами.
**Зачем AppDispatch**: обычный тип Dispatch принимает только объекты экшенов, поэтому dispatch thunk через нетипизированный useDispatch() не компилируется; typeof store.dispatch включает перегрузки для thunk.
Thunk: createAsyncThunk.withTypes<{ state: RootState; dispatch: AppDispatch }>() типизирует getState() в каждом thunk без повторения дженериков.
Состояние слайса: аннотируйте initialState интерфейсом, иначе status: 'idle' расширится до string; payload каждого экшена типизируйте через PayloadAction<T>.
Циклические импорты: слайсы импортируют RootState через import type, который стирается при сборке; если TypeScript всё же сообщает о циклической ссылке («implicitly has type 'any'»), её разрывает явный тип возврата у thunk или селектора.
withTypes требует React Redux 9.1+; старый код пишет useAppDispatch: () => AppDispatch = useDispatch.
Повседневный набор: Partial, Pick, Omit, Record, ReturnType, Parameters, Awaited и NonNullable, плюс собственный ComponentProps из React. Они выводят новые типы из одного исходного, поэтому изменение модели обновляет все зависящие от неё типы форм, патчей и пропсов.
**Omit<User, 'id'>: форма создания, пока сервер не присвоил id.
Partial<Pick<User, 'name' | 'email'>>: тело PATCH-запроса, где может меняться любое подмножество полей.
Record<Role, string>: таблица соответствий, которая должна покрывать каждый ключ объединения; пропущенная роль даёт ошибку компиляции.
Awaited<ReturnType<typeof fn>>: тип результата async-функции, которая вам не принадлежит.
Parameters<typeof fn>: кортеж аргументов для обёрток, которые пробрасывают аргументы.
NonNullable<T>**: убирает null и undefined после того, как вы их проверили.
**React.ComponentProps<typeof X>**: пропсы компонента, который не экспортирует свой тип пропсов.
value satisfies T проверяет, что значение соответствует T, но сохраняет собственный, более узкий выведенный тип значения. Аннотация : T расширяет значение до T и забывает детали; as T пропускает проверку и просто утверждает тип.
Аннотация: const routes: Record<string, Route> принимает любой строковый ключ, поэтому routes.setings компилируется и в рантайме равен undefined.
**satisfies**: объект по-прежнему проверяется на соответствие Record<string, Route>, но его ключи остаются известными, поэтому routes.setings даёт ошибку компиляции, а автодополнение показывает реальные ключи.
**as**: {} as Route компилируется без обоих полей; используйте его, только когда знаете больше компилятора, и никогда ради того, чтобы убрать ошибку.
Типичное применение в React: таблицы роутов, токены темы, объекты конфигурации и таблицы соответствий с ключами из объединения, где нужны и проверка, и литеральные типы.
Массив возвращают с as const или явно объявляют тип кортежа. Без этого TypeScript выводит массив объединения, (boolean | (() => void))[], поэтому после деструктуризации ни у одного элемента нет пригодного типа, а вызов toggle() даёт ошибку компиляции.
**as const** превращает [on, toggle] в readonly [boolean, () => void].
Явный тип результата (: [boolean, () => void]) документирует контракт и лучше подходит для экспортируемого общего хука.
Объектам ничего не нужно: return { data, error, isLoading } выводится точно, и это одна из причин выбирать объект, когда значений три и больше.
Дженерик-хуки: useLocalStorage<T>(key: string, initial: T) выводит T из initial, и сеттер типизируется от него.
as User лишь сообщает компилятору, как выглядит JSON; в рантайме это никто не проверяет, а API возвращает пользователей без address. При выполнении кода типы стираются, поэтому данные из сети нужно валидировать на границе, а не утверждать их тип.
res.json() возвращает Promise<any>, поэтому даже без as в User без проверки попала бы любая форма.
Исправление: опишите ответ схемой (Zod, Valibot) и вызывайте parse; z.infer<typeof UserSchema> даёт тип, так что тип и проверка в рантайме не могут разойтись.
Честно описывайте необязательные данные (.nullable()), и компилятор заставит проверить ?. в City.
Проверяйте и res.ok: иначе страница ошибки или тело 404 будут разобраны как пользователь.
Сгенерированные клиенты (OpenAPI, tRPC, GraphQL codegen) дают ту же гарантию, когда бэкенд публикует схему.
"strict": true включает семейство проверок, главные из них strictNullChecks (null и undefined становятся отдельными типами, которые нужно обработать) и noImplicitAny (параметр без типа даёт ошибку, а не молчаливый any). Без них TypeScript пропускает самые частые баги React-кода.
**Ещё в strict**: strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, useUnknownInCatchVariables (catch (e) имеет тип unknown), noImplicitThis, alwaysStrict.
**noUncheckedIndexedAccess**: items[0] и map[key] получают тип T | undefined, что ловит баги с пустыми массивами и отсутствующими ключами.
**noUnusedLocals / noUnusedParameters: мёртвый код ломает сборку.
noFallthroughCasesInSwitch для редьюсеров; verbatimModuleSyntax делает импорты только типов явными для бандлера.
Новые проекты** (шаблон Vite для React) сразу строгие; в старой кодовой базе включайте флаги по одному и предпочитайте // @ts-expect-error с причиной вместо // @ts-ignore.
Компонент делают дженериком по типу элемента, E extends React.ElementType, а его пропсы собирают из своих пропсов плюс React.ComponentProps<E> без ключей, которые вы определяете сами. TypeScript выводит E из значения as, поэтому набор допустимых атрибутов меняется вместе с ним.
Как это работает
as?: E со значением по умолчанию (E = 'span') управляет выводом типа: as="a" делает E литералом 'a', а ComponentProps<'a'> приносит href, target и ref ссылки.
Omit<ComponentProps<E>, keyof OwnProps | 'as'> не даёт нативному атрибуту конфликтовать с вашим (собственным size или color).
Внутри присвойте элемент переменной с типом React.ElementType, прежде чем рендерить его; рендер значения дженерик-типа E напрямую даёт ошибки, которые TypeScript не может разрешить.
Цена
Сложные дженерик-пропсы замедляют проверку типов в больших деревьях и дают длинные сообщения об ошибках.
Многие дизайн-системы теперь предпочитают паттерн asChild (Slot из Radix): компонент сливает свои пропсы в единственного потомка, <Button asChild><a href="/docs" /></Button>, и потомок сохраняет собственную нативную типизацию.
Режимы описывают объединением, а пропсы чужого режима запрещают необязательными полями never: контролируемый вариант требует value и onChange и содержит defaultValue?: never, неконтролируемый содержит value?: never. Смешивание или value без onChange не скомпилируется.
Почему never, а не просто объединение
Обычное объединение объектных типов не исключающее: объект с лишними свойствами из другого варианта всё ещё может подойти под одну из сторон. prop?: never явно говорит: «здесь этого пропса быть не должно».
Поскольку оба варианта объявляют каждый ключ, пропсы можно деструктурировать без предварительного сужения; каждый получает тип своего значения или undefined.
Компромиссы
Ошибка для неправильной комбинации называет весь тип пропсов, а не виноватый пропс; короткий комментарий у типа помогает вызывающему.
Типы покрывают места вызова, которые вы компилируете, но не значения в рантайме из спреда any; React по-прежнему предупреждает в рантайме, когда инпут переключается из неконтролируемого режима в контролируемый.
Тот же приём подходит для других взаимоисключающих пар: href против onClick, icon с обязательным aria-label.
Omit не дистрибутивен: он оставляет только ключи, общие для всех вариантов объединения, поэтому Omit<ButtonProps, 'className'> схлопывает объединение в { variant: 'link' | 'action' }, и href с onClick исчезают. Применяйте Omit к каждому варианту отдельно через дистрибутивный условный тип.
Omit<T, K> устроен как Pick<T, Exclude<keyof T, K>>, а keyof (A | B) содержит только ключи, которые есть у обоих вариантов: здесь variant и className.
Ошибка появляется дважды: внутри компонента («Property 'href' does not exist on type 'PrimaryButtonProps'») и в каждом месте вызова, <PrimaryButton variant="link" href="/docs" />.
Исправление: T extends unknown ? Omit<T, K> : never выполняется по одному разу для каждого варианта, потому что условный тип над «голым» параметром типа дистрибутивен; результат снова получается объединением и сужается по variant.
Partial, Required и Readonly проходят по keyof T напрямую и уже дистрибутивны по объединению; Pick и Omit нет.
Тот же коллапс случается с Omit<React.ComponentProps<typeof Button>, 'className'>, когда Button принимает пропсы-объединение, а именно так обычно и собирают обёртку.