TypeScript With React Patterns
Common TypeScript patterns for React components, hooks, and props.
TL;DR
- 01Type props with interfaces or types for component APIs.
- 02Use React.FC<Props> or function return types for components.
- 03Extend event types from React for type-safe handlers.
Tips
- 01Use discriminated unions for any component that accepts fundamentally different prop shapes — it removes the need for runtime checks.
Warnings
- 01Avoid over-typing or using any — specific types prevent bugs and make refactoring safer.
Discriminated Union Props
Define a multi-variant component using a type union with a shared discriminant.
type AlertProps = | { variant: "success"; message: string } | { variant: "error"; message: string; code: number }; function Alert(props: AlertProps) { return <div className={props.variant}>{props.message}</div>; }Access variant-specific props only after narrowing on the discriminant field.
function Alert(props: AlertProps) { if (props.variant === "error") { console.log(props.code); // only available in "error" branch } return <div>{props.message}</div>; }Use an exhaustive switch with never to catch unhandled variants at compile time.
function renderBadge(props: BadgeProps): React.ReactNode { switch (props.variant) { case "count": return <span>{props.count}</span>; case "dot": return <span className="dot" />; default: { const _never: never = props; // compile error if a variant is missing return null; } } }Model a button that accepts either a label or an icon, but not both.
type ButtonProps = | { kind: "label"; label: string } | { kind: "icon"; icon: React.ReactNode; ariaLabel: string }; function Button(props: ButtonProps) { return props.kind === "label" ? <button>{props.label}</button> : <button aria-label={props.ariaLabel}>{props.icon}</button>; }Require a message for error alerts but not for success alerts.
type ToastProps = | { type: "success"; title: string } | { type: "error"; title: string; detail: string }; // Callers must provide detail when type is "error" const t: ToastProps = { type: "error", title: "Failed", detail: "404" };
Polymorphic Components
Define helper types that extract the props for any HTML element or component.
type AsProps<E extends React.ElementType> = { as?: E; } & React.ComponentPropsWithoutRef<E>;Build a polymorphic Box component that types native props based on the as tag.
function Box<E extends React.ElementType = "div">( { as, ...rest }: AsProps<E> ) { const Tag = as ?? "div"; return <Tag {...rest} />; } // <Box as="a" href="/"> — href is required and typed // <Box as="button" onClick={fn}> — onClick is typed for buttonUse React.ElementType as a prop type for flexible tag or component injection.
interface HeadingProps { as?: React.ElementType; // accepts "h1", "h2", MyComponent, etc. children: React.ReactNode; } function Heading({ as: Tag = "h1", children }: HeadingProps) { return <Tag>{children}</Tag>; }Avoid unsafe casting with as unknown — let generics flow through instead.
// Bad: loses type safety function Box({ as: Tag = "div", ...rest }: any) { return <Tag {...(rest as any)} />; } // Good: generics preserve prop types function Box<E extends React.ElementType = "div">({ as, ...rest }: AsProps<E>) { const Tag = as ?? "div"; return <Tag {...rest} />; }Pass a custom component as the as prop to reuse any component's interface.
function Link({ href, children }: { href: string; children: React.ReactNode }) { return <a href={href}>{children}</a>; } // Box as Link — Box now accepts href because Link does <Box as={Link} href="/home">Home</Box>
Forward Ref Patterns
Use React.forwardRef with explicit generics to type the ref and props correctly.
const TextInput = React.forwardRef< HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement> >((props, ref) => <input ref={ref} {...props} />);Expose a custom handle interface using useImperativeHandle instead of the DOM ref.
interface DialogHandle { open: () => void; close: () => void; } const Dialog = React.forwardRef<DialogHandle, { children: React.ReactNode }>( ({ children }, ref) => { const [open, setOpen] = useState(false); useImperativeHandle(ref, () => ({ open: () => setOpen(true), close: () => setOpen(false) })); return open ? <div>{children}</div> : null; } );Use the forwarded ref at the call site with useRef typed to the handle.
const dialogRef = useRef<DialogHandle>(null); function Page() { return ( <> <button onClick={() => dialogRef.current?.open()}>Open</button> <Dialog ref={dialogRef}>Hello</Dialog> </> ); }Forward refs in component libraries to let consumers control focus.
const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>( (props, ref) => <select ref={ref} {...props} /> ); // Consumers can now do: const ref = useRef<HTMLSelectElement>(null);Assign displayName to forwardRef components for better DevTools labels.
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>( (props, ref) => <input ref={ref} {...props} /> ); Input.displayName = "Input";
Compound Components
Build a compound component by attaching child components as static properties.
function Table({ children }: { children: React.ReactNode }) { return <table>{children}</table>; } Table.Row = function Row({ children }: { children: React.ReactNode }) { return <tr>{children}</tr>; }; Table.Cell = function Cell({ children }: { children: React.ReactNode }) { return <td>{children}</td>; };Use dot-notation at the call site to keep related components grouped.
<Table> <Table.Row> <Table.Cell>Name</Table.Cell> <Table.Cell>Age</Table.Cell> </Table.Row> </Table>Share state between compound parts using context.
const AccordionCtx = React.createContext<{ open: string; setOpen: (id: string) => void } | null>(null); function Accordion({ children }: { children: React.ReactNode }) { const [open, setOpen] = useState(""); return <AccordionCtx.Provider value={{ open, setOpen }}>{children}</AccordionCtx.Provider>; }Type the static child properties explicitly on the parent component's type.
interface TableComponent { (props: { children: React.ReactNode }): JSX.Element; Row: (props: { children: React.ReactNode }) => JSX.Element; Cell: (props: { children: React.ReactNode }) => JSX.Element; } const Table: TableComponent = ({ children }) => <table>{children}</table>;Access shared context inside a child component to read parent state.
Accordion.Item = function Item({ id, title, children }: { id: string; title: string; children: React.ReactNode }) { const ctx = useContext(AccordionCtx)!; return ( <div> <button onClick={() => ctx.setOpen(id)}>{title}</button> {ctx.open === id && children} </div> ); };
Type Guards in Components
Write a type guard function to narrow a union type inside a component.
interface User { kind: "user"; name: string; } interface Admin { kind: "admin"; name: string; permissions: string[]; } function isAdmin(person: User | Admin): person is Admin { return person.kind === "admin"; }Use the in operator as an inline type guard to check for a key's presence.
function ProfileCard({ person }: { person: User | Admin }) { return ( <div> <p>{person.name}</p> {"permissions" in person && <p>Permissions: {person.permissions.join(", ")}</p>} </div> ); }Narrow a discriminated union inside render using the kind field.
function Notification(props: { type: "info"; text: string } | { type: "alert"; text: string; level: number }) { if (props.type === "alert") { return <div className={`alert-${props.level}`}>{props.text}</div>; } return <div>{props.text}</div>; }Assert a type inside an event handler when you know the target element.
function Form() { const handleChange = (e: React.ChangeEvent<HTMLElement>) => { if (e.target instanceof HTMLInputElement) { console.log(e.target.value); // now typed as string } }; return <input onChange={handleChange} />; }Use a type guard to safely render only the correct variant of a union prop.
type Content = { kind: "text"; body: string } | { kind: "image"; src: string; alt: string }; function isImageContent(c: Content): c is Extract<Content, { kind: "image" }> { return c.kind === "image"; } function Render({ content }: { content: Content }) { return isImageContent(content) ? <img src={content.src} alt={content.alt} /> : <p>{content.body}</p>; }
FAQ
Prefer explicit return types like : JSX.Element or : React.ReactElement over React.FC<Props> — React.FC implicitly includes children in older versions and adds unnecessary complexity. Typing the function directly gives you cleaner inference and more control.
Use React.ChangeEvent<HTMLInputElement> as the event type: onChange: (e: React.ChangeEvent<HTMLInputElement>) => void. React exports event types for all DOM elements, so swap HTMLInputElement for the appropriate element type like HTMLSelectElement or HTMLTextAreaElement.
Declare a type parameter on the function: function List<T>({ items, render }: { items: T[]; render: (item: T) => React.ReactNode }). This lets callers pass any array type while still getting full type safety on the items.
Both work equally well for component props, but interface supports declaration merging, which is useful when library consumers need to extend your prop types. Use type when you need union types or mapped types that interface can't express.
Add as const to the returned array: return [value, setValue] as const — without it, TypeScript widens the type to (string | Dispatch<...>)[] instead of the precise tuple type. Alternatively, explicitly annotate the return type as [string, Dispatch<SetStateAction<string>>].