🚀AdvancedAdvanced Integration
TypeScript with React - Advanced Patterns
Master advanced TypeScript patterns for React: polymorphic components, generic components, discriminated union props, forward refs, and typed context.
Overview
Basic React with TypeScript is straightforward: type your props and state. Advanced TypeScript unlocks patterns that make React components dramatically safer and more expressive.
This topic covers:
- **Polymorphic components** (the `as` prop pattern): render a component as any HTML element or another component while preserving correct prop types
- **Generic components**: components where the prop type depends on a type parameter, like a typed `<List<T>>` or `<Select<T>>`
- **Discriminated union props**: component APIs that enforce mutually exclusive prop combinations at compile time
- **Properly typed `forwardRef`**: getting correct ref types when forwarding refs
- **Typed `createContext`**: context without the nullable-initial-value problem
These patterns appear in major component libraries (Radix UI, Mantine, Ark UI) and enable building reusable UI systems that are as type-safe as they are flexible.
Key Points
- •Polymorphic `as` prop: `<Box as="button">` types props as `ButtonHTMLAttributes`, `<Box as="a">` as `AnchorHTMLAttributes`
- •Generic components: `function List<T>({ items, render }: { items: T[]; render: (item: T) => ReactNode })`
- •Discriminated union props: mutually exclusive prop groups with `never` to block invalid combinations
- •Typed `forwardRef`: `React.forwardRef<HTMLInputElement, InputProps>((props, ref) => ...)` - ref type as first generic
- •Typed context: `createContext<ContextType | null>(null)` with a custom `useCtx` hook that asserts non-null
- •Event handler types: `React.MouseEventHandler<HTMLButtonElement>`, `React.ChangeEvent<HTMLInputElement>`
- •Component prop extraction: `React.ComponentProps<typeof MyComponent>`, `React.ComponentPropsWithRef<"button">`
Examples
Polymorphic component with the `as` prop pattern
import React from 'react';
// The polymorphic props type: takes any HTML element or component
type AsProp<C extends React.ElementType> = {
as?: C;
};
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);
type PolymorphicProps<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;
// A flexible Box component that renders as any element
type BoxProps<C extends React.ElementType> = PolymorphicProps<
C,
{ className?: string }
>;
function Box<C extends React.ElementType = 'div'>({
as,
children,
className,
...rest
}: BoxProps<C>) {
const Component = as || 'div';
return (
<Component className={className} {...rest}>
{children}
</Component>
);
}
// Usage - prop types change based on 'as'
<Box>Hello</Box> // div props
<Box as="button" onClick={() => {}}>Click</Box> // button props including onClick
<Box as="a" href="/about">About</Box> // anchor props including href
// <Box as="a" onClick={() => {}}> // TypeScript: onClick is not in AnchorHTMLAttributes
// <Box as="button" href="/about"> // TypeScript: href is not in ButtonHTMLAttributesGeneric components and discriminated union props
import React from 'react';
// Generic List component - T is inferred from the items prop
interface ListProps<T> {
items: T[];
getKey: (item: T) => string;
render: (item: T, index: number) => React.ReactNode;
emptyMessage?: string;
}
function List<T>({ items, getKey, render, emptyMessage = 'No items' }: ListProps<T>) {
if (items.length === 0) return <p>{emptyMessage}</p>;
return (
<ul>
{items.map((item, i) => (
<li key={getKey(item)}>{render(item, i)}</li>
))}
</ul>
);
}
// T is inferred from the items array
const users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }];
<List
items={users}
getKey={u => u.id}
render={u => <span>{u.name}</span>} // u is typed as { id: string; name: string }
/>;
// Discriminated union props - mutually exclusive groups
type ButtonProps =
| { variant: 'primary'; href?: never; type?: 'button' | 'submit' }
| { variant: 'link'; href: string; type?: never };
function Button(props: ButtonProps) {
if (props.variant === 'link') {
return <a href={props.href}>{/* href is required here */}</a>;
}
return <button type={props.type ?? 'button'}>{/* type is relevant here */}</button>;
}
<Button variant="primary" type="submit" /> // OK
<Button variant="link" href="/about" /> // OK
// <Button variant="primary" href="/about" /> // Error - href not valid for primary
// <Button variant="link" /> // Error - href required for linkTyped forwardRef and typed createContext
import React, { createContext, useContext, forwardRef } from 'react';
// Typed forwardRef - first generic is the element type, second is props
interface CustomInputProps {
label: string;
error?: string;
}
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
({ label, error, ...rest }, ref) => (
<div>
<label>{label}</label>
<input ref={ref} aria-invalid={!!error} {...rest} />
{error && <span role="alert">{error}</span>}
</div>
)
);
CustomInput.displayName = 'CustomInput';
// Usage - ref is typed as RefObject<HTMLInputElement>
const inputRef = React.useRef<HTMLInputElement>(null);
<CustomInput ref={inputRef} label="Email" />;
// inputRef.current is HTMLInputElement - fully typed
// Typed createContext with non-null assertion hook
interface ThemeContext {
mode: 'light' | 'dark';
toggle: () => void;
}
const ThemeCtx = createContext<ThemeContext | null>(null);
// Custom hook that asserts the context is not null
function useTheme(): ThemeContext {
const ctx = useContext(ThemeCtx);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = React.useState<'light' | 'dark'>('light');
return (
<ThemeCtx.Provider value={{ mode, toggle: () => setMode(m => m === 'light' ? 'dark' : 'light') }}>
{children}
</ThemeCtx.Provider>
);
}
// Consumer - no null check needed, useTheme guarantees a value
function ThemedButton({ children }: { children: React.ReactNode }) {
const { mode, toggle } = useTheme(); // typed as ThemeContext, never null
return (
<button
style={{ background: mode === 'dark' ? '#333' : '#fff' }}
onClick={toggle}
>
{children}
</button>
);
}Common Mistakes
- Using `React.FC` (FunctionComponent) - it adds an implicit `children` prop in React 17 and older, and its relationship with generics is awkward. Prefer plain function declarations with explicit prop types
- Typing event handlers as `any`: use specific types like `React.MouseEvent<HTMLButtonElement>` or the handler shorthand `React.MouseEventHandler<HTMLButtonElement>` to get the correct event and target types
- Forgetting `displayName` on `forwardRef` components: React DevTools shows anonymous components in the tree without it, making debugging difficult
- Creating context with a default value of `null` and then using non-null assertion `!` everywhere in consumers: the `useCtx` hook pattern centralizes the assertion and throws a helpful error message
Note
Use `React.ComponentProps<typeof SomeComponent>` to extract the full prop type of a component you do not own. Use `React.ComponentPropsWithRef<"button">` to get HTML element props including the `ref` prop. These utilities are invaluable when building wrapper components that forward all props transparently.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Loading editor…
Completed: 0 / 2 exercises
Knowledge Check
Question 1 of 4