23 — TypeScript with React
Typing Component Props
typing_props.tsx
import { type ReactNode, type PropsWithChildren } from 'react'
// BASIC: interface for props
interface ButtonProps {
label: string
variant?: 'primary' | 'secondary' | 'danger' // union = autocomplete + exhaustiveness
size?: 'sm' | 'md' | 'lg'
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean
children?: ReactNode // anything renderable: string, element, array of elements
}
function Button({ label, variant = 'primary', size = 'md', onClick, disabled, children }: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{label || children}
</button>
)
}
// type vs interface: use `type` for unions and computed types, `interface` for
// object shapes that might be extended. For props, `interface` is conventional
// but `type` works identically. Prefer `type` if you don't need declaration merging.
Discriminated Unions for Variant Props
discriminated_unions.tsx
// DISCRIMINATED UNION: different prop shapes for different variants.
// The `variant` field is the discriminant — TypeScript narrows based on it.
type InputProps =
| { variant: 'text'; value: string; onChange: (v: string) => void; placeholder?: string }
| { variant: 'number'; value: number; onChange: (v: number) => void; min?: number; max?: number }
| { variant: 'checkbox'; checked: boolean; onChange: (v: boolean) => void; label: string }
function Input(props: InputProps) {
switch (props.variant) {
case 'text':
// TS knows: props is { variant: 'text'; value: string; ... }
// props.min would be a TYPE ERROR here — text variant doesn't have min
return <input type="text" value={props.value} placeholder={props.placeholder}
onChange={e => props.onChange(e.target.value)} />
case 'number':
// TS knows: props is { variant: 'number'; value: number; min?: number; ... }
return <input type="number" value={props.value} min={props.min} max={props.max}
onChange={e => props.onChange(Number(e.target.value))} />
case 'checkbox':
// TS knows: props is { variant: 'checkbox'; checked: boolean; label: string }
return <label><input type="checkbox" checked={props.checked}
onChange={e => props.onChange(e.target.checked)} /> {props.label}</label>
}
}
// Usage: <Input variant="text" value="hello" onChange={setStr} />
// TS enforces the correct prop shape per variant — wrong props are type errors.
Typing Event Handlers
typing_events.tsx
// React provides typed event interfaces. The generic is the DOM element type.
function Form() {
const [email, setEmail] = useState('')
// React.ChangeEvent<HTMLInputElement> — fires on input/textarea/select change
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value) // TS knows e.target is HTMLInputElement → .value is string
}
// React.MouseEvent<HTMLButtonElement> — fires on click
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
// TS knows e.currentTarget is HTMLButtonElement → .disabled, .form, etc.
}
// React.KeyboardEvent<HTMLDivElement> — fires on keydown/keyup
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') submit()
// e.key is string; e.keyCode is deprecated; e.code gives physical key
}
// React.FormEvent<HTMLFormElement> — fires on form submit
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget) // e.currentTarget = <form>
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={handleChange} onKeyDown={handleKeyDown} />
<button onClick={handleClick}>Submit</button>
</form>
)
}
Typing Hooks
typing_hooks.tsx
// useState: inferred from initial value, or explicit generic
const [count, setCount] = useState(0) // infers number
const [user, setUser] = useState<User | null>(null) // explicit: User or null
const [items, setItems] = useState<string[]>([]) // explicit: string array
// useRef: the generic is the type of .current
const inputRef = useRef<HTMLInputElement>(null) // .current: HTMLInputElement | null
const timerRef = useRef<number | null>(null) // for setInterval/setTimeout IDs
// NOTE: useRef(null) → .current is null initially. Always null-check: inputRef.current?.focus()
// createContext: the generic is the context value type
interface ThemeContextValue { theme: 'light' | 'dark'; toggle: () => void }
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
// undefined default → enables the "outside provider" check in useTheme():
function useTheme() {
const ctx = useContext(ThemeContext)
if (ctx === undefined) throw new Error('useTheme must be used within ThemeProvider')
return ctx // TS knows ctx is ThemeContextValue (not undefined) after the throw
}
// useReducer: type the state and action
type Action = { type: 'increment' } | { type: 'setStep'; step: number }
const [state, dispatch] = useReducer(reducer, initialState)
// dispatch is typed: dispatch({ type: 'increment' }) ✓
// dispatch({ type: 'setStep' }) ✗ — missing required `step` field
// dispatch({ type: 'decrement' }) ✗ — 'decrement' not in the Action union
Typing Refs and forwardRef
typing_forwardref.tsx
import { forwardRef, useRef, useImperativeHandle } from 'react'
// forwardRef: the first generic is the ref type (the DOM element), second is props
const FancyInput = forwardRef<HTMLInputElement, FancyInputProps>(
function FancyInput(props, ref) {
return <input ref={ref} {...props} />
}
)
// useImperativeHandle: define a custom ref API type
interface InputHandle {
focus: () => void
clear: () => void
getValue: () => string
}
const ControlledInput = forwardRef<InputHandle, ControlledInputProps>(
function ControlledInput(props, ref) {
const inputRef = useRef<HTMLInputElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => { if (inputRef.current) inputRef.current.value = '' },
getValue: () => inputRef.current?.value ?? '',
}), [])
return <input ref={inputRef} {...props} />
}
)
// Parent: const ref = useRef<InputHandle>(null) → ref.current?.focus() is typed
Utility Types for Props
utility_types.tsx
import { type ComponentProps, type PropsWithChildren, type HTMLAttributes } from 'react'
// ComponentProps<typeof Button> — extract the prop types of an existing component
type ButtonProps = ComponentProps<typeof Button>
// Useful for wrapping: function MyButton(props: ButtonProps) { return <Button {...props} /> }
// PropsWithChildren — adds `children?: ReactNode` to any props type
type CardProps = PropsWithChildren<{ title: string }>
function Card({ title, children }: CardProps) {
return <div><h3>{title}</h3>{children}</div>
}
// HTMLAttributes<HTMLDivElement> — all standard HTML attributes for a div
type DivProps = HTMLAttributes<HTMLDivElement>
// Includes onClick, className, style, role, aria-*, data-*, etc.
// Omit to remove props you're overriding
type CustomButtonProps = Omit<ComponentProps<'button'>, 'onClick'> & {
onSelect: (id: string) => void // replace onClick with a different handler
}
// Pick to select only specific props
type OnlyClickProps = Pick<ComponentProps<'button'>, 'onClick' | 'disabled'>
💡 Tips & Tricks
tips.tsx
// [Idiom] Use `type` for props (not `interface`) when you need unions, intersections,
// or utility types. Use `interface` when you need declaration merging (rare for props).
// [Idiom] For event handlers passed as props, type them explicitly:
// onChange: (value: string) => void — not (e: any) => void
// This makes the component's API self-documenting and catches type mismatches.
// [Idiom] Prefer discriminated unions over optional props for variant components.
// `variant: 'text' | 'number'` with per-variant props catches more bugs than
// `type?: string; min?: number; checked?: boolean` with everything optional.
// [Debug] If TS complains about a ref being null, add the null check:
// inputRef.current?.focus() — not inputRef.current.focus()
// useRef<T>(null) → T | null, not T. The null check is required by the type.
// [Idiom] Type the return of custom hooks explicitly for complex returns:
// function useFetch(url: string): { data: T | null; loading: boolean; error: string | null }
⚠️ Edge Cases & Gotchas
edge_cases.tsx
// [Gotcha] useRef<T>(null) creates a ref of type RefObject<T | null>. You can't
// pass it to a DOM element that expects Ref<T> without the null check.
// Use useRef<T>(null!) to assert non-null ONLY if you're certain it's set
// before access — but prefer the safe null-check pattern.
// [Gotcha] Children type: ReactNode includes null, undefined, string, number,
// boolean, element, array. JSX.Element is narrower (only rendered elements).
// Use ReactNode for children props — JSX.Element is too restrictive.
// [Gotcha] Event handler generics must match the DOM element. React.ChangeEvent
// <HTMLTextAreaElement> on an <input> → e.target is textarea, not input →
// wrong property access compiles but is semantically wrong.
// [Gotcha] The `exactOptionalPropertyTypes` TS config changes optional prop
// behavior: { x?: string } no longer accepts { x: undefined }. This affects
// React props — check your tsconfig before assuming optional = allows undefined.
// [Gotcha] forwardRef components need explicit generic types — TS can't infer
// the ref type from the JSX. Always: forwardRef<RefType, PropsType>(...)
🧠 Spot the Bug
A component receives a ref prop but TypeScript says it's not a valid prop:
spot_the_bug.tsx
function MyInput(props: { value: string; onChange: (v: string) => void; ref?: React.Ref<HTMLInputElement> }) {
return <input ref={props.ref} value={props.value} onChange={e => props.onChange(e.target.value)} />
}
// Parent: <MyInput ref={inputRef} ... /> → TS error: ref is not a valid prop
Answer
ref is not a regular prop in React — it's special. React intercepts ref and doesn't pass it through to props. You can't type it as a regular prop and expect it to work. You must use forwardRef:
const MyInput = forwardRef<HTMLInputElement, Omit<MyInputProps, 'ref'>>(
function MyInput(props, ref) {
return <input ref={ref} value={props.value} onChange={e => props.onChange(e.target.value)} />
}
)
The same applies to key — React also intercepts it and doesn't pass it as a prop.
Key Takeaways
key_takeaways.tsx
// 1. Type props with interface or type. Use discriminated unions for variant
// components — the discriminant gives exhaustive switch checking + autocomplete.
// 2. Event types: React.ChangeEvent<T>, React.MouseEvent<T>, React.KeyboardEvent<T>,
// React.FormEvent<T>. The generic is the DOM element type → e.target is typed.
// 3. Hooks: useState<T>(initial), useRef<T>(null), createContext<T | undefined>(undefined),
// useReducer<State, Action>. Always type the Action as a discriminated union.
// 4. forwardRef needs explicit generics: forwardRef<RefType, PropsType>(...).
// useImperativeHandle exposes a typed handle: forwardRef<Handle, Props>.
// 5. Utility types: ComponentProps<typeof X> (extract props), PropsWithChildren<P>
// (add children), Omit<P, 'x'> (remove), Pick<P, 'x' | 'y'> (select).
// Use ReactNode (not JSX.Element) for children — it's the broader, correct type.