🚀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 ButtonHTMLAttributes

Generic 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 link

Typed 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

What is the main benefit of the polymorphic `as` prop pattern?