Describe the props with an interface or a type alias and annotate the function's props parameter; TypeScript then checks every place the component is used. React.FC is not needed: a plain function with typed props is the common convention.
**interface or type**: both work for props. interface can be extended with extends; a type alias is required for unions such as variant props.
Optional props use ?, and defaults go in the destructuring (variant = 'primary'). React 19 removed defaultProps for function components.
Literal unions ('primary' | 'secondary') instead of string turn a typo into a compile error.
**Why not React.FC**: it adds nothing a plain annotation lacks, and it cannot express a generic component. Since React 18 types it no longer adds an implicit children, so old code that relied on that breaks.
ReactMiddle
TypeScript with React
Typing props, children and events, generic components, discriminated unions, utility types, typing hooks and context, strict mode
Questions closed
0 / 21
- 0Review
- 8Asked often
Questions
Type children as React.ReactNode when the component accepts anything React can render: strings, numbers, elements, arrays, fragments, null, undefined and booleans. ReactElement and JSX.Element accept only an element, not text or null.
**ReactNode**: the right default for children and for props like icon or footer.
**ReactElement**: what JSX evaluates to (<Icon />); use it only when you must clone or inspect the element, which is rare.
**JSX.Element**: the same as ReactElement<any, any>. In React 19 types the global JSX namespace is gone; write React.JSX.Element.
**PropsWithChildren<P>** adds children?: ReactNode to existing props.
Render prop as children: type it as a function, children: (item: T) => ReactNode.
Inline handlers need no annotation: TypeScript infers the event type from the JSX attribute. For a handler declared separately, use React's event types with the element as the parameter, such as React.ChangeEvent<HTMLInputElement> or React.SubmitEvent<HTMLFormElement>.
Common types: ChangeEvent for inputs, selects and textareas; SubmitEvent for onSubmit (current @types/react deprecates the older FormEvent); MouseEvent for clicks; KeyboardEvent for keys; FocusEvent for focus and blur.
Handler types: React.ChangeEventHandler<HTMLInputElement> types the whole function at once.
**currentTarget vs target**: currentTarget is the element the handler is attached to and is typed as it; target is where the event started and, for most events, is only an EventTarget.
These are React's synthetic events, not the DOM's Event; e.nativeEvent gives the underlying DOM event.
Pass the type explicitly as a generic: useState<User | null>(null), useState<string[]>([]). Without it TypeScript infers the type from the initial value, so null would allow only null and [] would become never[].
Inference is enough for primitives and complete objects: useState(0) is number, useState('') is string.
Union of literals for status fields: useState<'idle' | 'loading' | 'error'>('idle').
Optional value: useState<string>() gives string | undefined.
Do not lie with useState<User>({} as User): it compiles and crashes when a field is read before the data arrives.
The setter is typed for free: setUser accepts a User, null or an updater function.
Type a DOM ref with the element and an initial null: useRef<HTMLInputElement>(null), whose .current is HTMLInputElement | null, so TypeScript makes you check it. In React 19 ref is an ordinary prop, so a component declares it in its props and no longer needs forwardRef.
DOM ref: current is null until mount and after unmount; use ref.current?.focus().
Value ref: useRef(0) or useRef<number | null>(null) for a timer id; it is a mutable box, not tied to an element.
React 19 types: useRef always requires an argument, and the MutableRefObject type is deprecated in favour of RefObject.
Accepting a ref: React.ComponentProps<'input'> already includes ref; or declare ref?: React.Ref<HTMLInputElement> yourself.
forwardRef still works but is no longer needed and is expected to be deprecated.
Build the props from React.ComponentProps<'button'> and intersect your own: the wrapper then accepts every attribute, event and ref that <button> does, and you spread the rest onto the element. Use Omit when your prop must replace a native one with a different type.
**ComponentProps<'button'>** includes ref in React 19; ComponentPropsWithoutRef exists for when you must not forward it.
**Omit<..., 'size'>**: the native size on <input> is a number; omit it before declaring your own size: 'sm' | 'lg', or the intersection becomes never.
**ComponentProps<typeof Button> reads the props of an existing component, handy for a wrapper of a wrapper or a third-party component that does not export its props type.
Merge className** instead of letting the caller's value silently replace yours.
Spreading ...rest last lets callers override anything; spread it first if some props must win.
A discriminated union is a union of object types that share a literal field (the discriminant, such as status), so checking that field narrows the value to one member. It makes impossible states unrepresentable: data exists only when status is 'success'.
Instead of flags: isLoading, error? and data? allow nonsense combinations (loading with an error and data). A union allows exactly the states that exist.
Narrowing: inside case 'error': TypeScript knows state.error exists; outside it, reading state.error is a compile error.
Props too: { kind: 'link'; href: string } | { kind: 'button'; onClick: () => void } makes href required for links and forbidden for buttons.
Exhaustiveness: with every case returning, adding a new status without handling it fails the build (noImplicitReturns, or an explicit never check).
Libraries use the same shape: TanStack Query's result narrows data when you check status === 'success'.
It does not compile: const { kind, href } = props reports "Property 'href' does not exist on type 'ActionProps'". Destructuring reads href from the whole union before any narrowing, and the button member has no href.
TypeScript lets you read only properties that every member of a union has; kind is shared, href and onClick are not.
Fix 1: narrow first, then read: if (props.kind === 'link') return <a href={props.href}>.
Fix 2: destructure inside the branch, after the check.
Fix 3: add href?: never to the button member. Destructuring then compiles, and TypeScript (4.6+) narrows the destructured href to string after kind === 'link'.
The same rule explains why props.onClick in the last line is fine: after the link branch returns, props is narrowed to the button member.
Declare the component as a generic function, function List<T>(props: ListProps<T>), and use T in the props. TypeScript infers T from what the caller passes in items, so callbacks such as renderItem receive the real item type with no annotations.
Constraints: T extends { id: string } lets the component read item.id for keys.
**Arrow functions in .tsx**: write <T,> (or <T extends unknown>), otherwise the parser reads <T> as a JSX tag.
**memo loses the generic**: memo(List) returns a non-generic component; cast it back with as typeof List, or let the React Compiler memoize instead.
Refs are easy now: in React 19 ref is a prop, so a generic component accepts one without the old forwardRef workaround, which also erased generics.
Make the HOC generic over the wrapped component's props, P extends InjectedProps, and give the returned component Omit<P, keyof InjectedProps>. Callers then pass only their own props, and inside the HOC a single cast puts the injected ones back.
Inference: withUser(Greeting) infers P from Greeting, so the wrapper's props are derived, not written by hand.
Why the cast: Omit<P, 'user'> plus user is not provably P, since P may declare a narrower user (an AdminUser), and TypeScript reports that P "could be instantiated with a different subtype of constraint". The cast stays inside the HOC; call sites are still checked.
**displayName**: derive it from the wrapped component, so React DevTools shows withUser(Greeting).
Refs: in React 19 ref is a plain prop and passes through the spread; React 18 needed forwardRef.
Hooks avoid the problem: useCurrentUser() or a render prop hands the value over directly, with no Omit and no cast, one reason hooks replaced most HOCs.
Create the context as createContext<Value | null>(null) and expose it only through a custom hook that throws when the provider is missing. The hook's return type is then Value, so every consumer gets a non-null value and a clear error if the provider was forgotten.
Why not a fake default: createContext({} as Value) compiles, but a component rendered outside the provider reads undefined fields and fails far from the cause.
**Why not !**: useContext(Ctx)! silences the compiler in every consumer instead of checking once.
Real default: when a sensible value exists (a theme, a locale), pass it to createContext and skip the null.
Export the hook, not the context: callers cannot bypass the check, and the context type stays an implementation detail.
Type the actions as a discriminated union on type, annotate the reducer's state, action and return type, and let useReducer infer the rest from the reducer. A default branch that assigns action to a never variable fails to compile when a new action type has no case.
Payload per action: inside case 'setStep': TypeScript knows action.step exists; dispatch({ type: 'setStep' }) without it is an error.
**Typed dispatch: callers can only send actions from the union, with the right payload.
Do not pass type arguments** to useReducer: in React 19 types its generic parameters changed (useReducer<State, [Action]> instead of useReducer<Reducer<State, Action>>), and inference from a typed reducer works in both.
Explicit return type on the reducer stops one branch from silently returning a wrong shape.
Infer both from the store, RootState = ReturnType<typeof store.getState> and AppDispatch = typeof store.dispatch, then create pre-typed hooks once with useSelector.withTypes<RootState>() and useDispatch.withTypes<AppDispatch>() and use only those in components.
Inferred, not written: a new slice extends RootState automatically; a hand-written interface drifts from the reducers.
**Why AppDispatch**: the plain Dispatch type accepts only action objects, so dispatching a thunk through an untyped useDispatch() fails to compile; typeof store.dispatch includes the thunk overloads.
Thunks: createAsyncThunk.withTypes<{ state: RootState; dispatch: AppDispatch }>() types getState() in every thunk without repeating generics.
Slice state: annotate initialState with an interface, or status: 'idle' widens to string; type each payload with PayloadAction<T>.
Import cycles: slices import RootState with import type, erased at build time; if TypeScript still reports a circular reference ("implicitly has type 'any'"), an explicit return type on the thunk or selector breaks it.
withTypes needs React Redux 9.1+; older code writes useAppDispatch: () => AppDispatch = useDispatch.
The everyday set is Partial, Pick, Omit, Record, ReturnType, Parameters, Awaited and NonNullable, plus React's own ComponentProps. They derive new types from one source type, so a change to the model updates every form, patch and prop type that depends on it.
**Omit<User, 'id'>: a create form before the server assigns an id.
Partial<Pick<User, 'name' | 'email'>>: a PATCH body where any subset of fields may change.
Record<Role, string>: a lookup table that must cover every key of a union; a missing role is a compile error.
Awaited<ReturnType<typeof fn>>: the resolved type of an async function you do not own.
Parameters<typeof fn>: the argument tuple, for wrappers that forward arguments.
NonNullable<T>**: removes null and undefined after you have checked for them.
**React.ComponentProps<typeof X>**: the props of a component that does not export its props type.
value satisfies T checks that the value matches T but keeps the value's own, narrower inferred type. An annotation : T widens the value to T and forgets the details; as T skips the check and simply asserts.
Annotation: const routes: Record<string, Route> accepts any string key, so routes.setings compiles and is undefined at runtime.
**satisfies**: the object is still checked against Record<string, Route>, but its keys stay known, so routes.setings is a compile error and autocomplete lists real keys.
**as**: {} as Route compiles with both fields missing; use it only when you know more than the compiler, never to make an error go away.
Typical React uses: route tables, theme tokens, config objects and lookup maps keyed by a union, where you want both the check and the literal types.
Return the array as const or declare the tuple return type explicitly. Without it TypeScript infers an array of a union, (boolean | (() => void))[], so after destructuring neither element has a usable type and calling toggle() is a compile error.
**as const** turns [on, toggle] into readonly [boolean, () => void].
Explicit return type (: [boolean, () => void]) documents the contract and is the better choice for an exported, shared hook.
Objects need nothing: return { data, error, isLoading } is inferred precisely, which is one reason to prefer an object once there are three or more values.
Generic hooks: useLocalStorage<T>(key: string, initial: T) infers T from initial, and the setter is typed from it.
as User only tells the compiler what the JSON looks like; nothing checks it at runtime, and the API returns users without an address. Types are erased when the code runs, so data from the network must be validated at the boundary, not asserted.
res.json() returns Promise<any>, so even without as any shape would flow into User unchecked.
Fix: describe the response with a schema (Zod, Valibot) and parse it; z.infer<typeof UserSchema> gives the type, so the type and the runtime check cannot drift apart.
Model optional data honestly (.nullable()), and the compiler then forces the ?. check in City.
Also check res.ok: an error page or a 404 body would otherwise be parsed as a user.
Generated clients (OpenAPI, tRPC, GraphQL codegen) give the same guarantee when the backend publishes a schema.
"strict": true enables a family of checks, most importantly strictNullChecks (null and undefined are separate types you must handle) and noImplicitAny (an untyped parameter is an error, not a silent any). Without them TypeScript misses the bugs React code hits most.
**Also in strict**: strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, useUnknownInCatchVariables (catch (e) is unknown), noImplicitThis, alwaysStrict.
**noUncheckedIndexedAccess**: items[0] and map[key] become T | undefined, catching empty-array and missing-key bugs.
**noUnusedLocals / noUnusedParameters: dead code fails the build.
noFallthroughCasesInSwitch for reducers; verbatimModuleSyntax makes type-only imports explicit for the bundler.
New projects** (the Vite React template) start strict; for an old codebase, turn flags on one at a time and prefer // @ts-expect-error with a reason over // @ts-ignore.
Make the component generic over the element type, E extends React.ElementType, and build its props as your own props plus React.ComponentProps<E> minus the keys you define. TypeScript infers E from the as value, so the allowed attributes change with it.
How it works
as?: E with a default (E = 'span') drives inference: as="a" makes E the literal 'a', and ComponentProps<'a'> brings in href, target and the anchor's ref.
Omit<ComponentProps<E>, keyof OwnProps | 'as'> stops a native attribute from clashing with yours (a custom size or color).
Inside, assign the element to a variable typed React.ElementType before rendering it; rendering a value of the generic type E directly produces errors TypeScript cannot resolve.
The cost
Complex generic props slow type checking in large trees and produce long error messages.
Many design systems now prefer the asChild pattern (Radix Slot): the component merges its props into the single child, <Button asChild><a href="/docs" /></Button>, so the child keeps its own native typing.
Model the two modes as a union and forbid the other mode's props with optional never fields: the controlled member requires value and onChange and has defaultValue?: never, the uncontrolled one has value?: never. Mixing them, or value without onChange, then fails to compile.
Why never and not just a union
A plain union of object types is not exclusive: an object with extra properties from the other member can still match one side. prop?: never explicitly says "this prop must be absent here".
Because both members declare every key, the props can be destructured without narrowing first; each one is typed as its value or undefined.
Trade-offs
The error for a wrong combination names the whole props type rather than the offending prop; a short comment on the type helps the caller.
Types cover call sites you compile, not runtime values from spreads of any; React still warns at runtime when an input switches from uncontrolled to controlled.
The same technique fits other exclusive pairs: href versus onClick, icon with a required aria-label.
Omit is not distributive: it keeps only the keys that every member of the union shares, so Omit<ButtonProps, 'className'> collapses the union into { variant: 'link' | 'action' }, and href and onClick disappear. Apply Omit to each member separately with a distributive conditional type.
Omit<T, K> is Pick<T, Exclude<keyof T, K>>, and keyof (A | B) contains only the keys both members have: here variant and className.
The error shows up twice: inside the component ("Property 'href' does not exist on type 'PrimaryButtonProps'") and at every call site, <PrimaryButton variant="link" href="/docs" />.
Fix: T extends unknown ? Omit<T, K> : never runs once per union member, because a conditional type over a bare type parameter distributes; the result is a union again and narrows on variant.
Partial, Required and Readonly map over keyof T directly and already distribute over a union; Pick and Omit do not.
The same collapse hits Omit<React.ComponentProps<typeof Button>, 'className'> when Button takes union props, the usual way a wrapper is built.