Shoaib Khan

Type-safe Component Patterns in React

4 min read
TypeScriptReactComponents

Building reusable components with TypeScript requires careful consideration of APIs, flexibility, and developer experience. Here are patterns I've refined while building design systems used by 6+ teams.

Polymorphic Components

Create components that can render as different HTML elements:

type AsProp<C extends React.ElementType> = {
  as?: C;
};

type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);

type PolymorphicComponentProp<
  C extends React.ElementType,
  Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
  Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'md' | 'lg';
}

const Button = <C extends React.ElementType = 'button'>({
  as,
  variant = 'primary',
  size = 'md',
  children,
  ...props
}: PolymorphicComponentProp<C, ButtonProps>) => {
  const Component = as || 'button';

  return (
    <Component
      className={`btn btn-${variant} btn-${size}`}
      {...props}
    >
      {children}
    </Component>
  );
};

// Usage
<Button>Default button</Button>
<Button as="a" href="/login">Link button</Button>
<Button as={Link} to="/dashboard">Router link</Button>

Compound Components

Build flexible APIs with compound patterns:

interface SelectContextValue {
  value: string;
  onChange: (value: string) => void;
  isOpen: boolean;
  setIsOpen: (open: boolean) => void;
}

const SelectContext = React.createContext<SelectContextValue | null>(null);

const useSelectContext = () => {
  const context = React.useContext(SelectContext);
  if (!context) {
    throw new Error("Select components must be used within Select");
  }
  return context;
};

const Select = ({
  value,
  onValueChange,
  children,
}: {
  value: string;
  onValueChange: (value: string) => void;
  children: React.ReactNode;
}) => {
  const [isOpen, setIsOpen] = React.useState(false);

  return (
    <SelectContext.Provider
      value={{
        value,
        onChange: onValueChange,
        isOpen,
        setIsOpen,
      }}
    >
      <div className="select">{children}</div>
    </SelectContext.Provider>
  );
};

const SelectTrigger = ({ children }: { children: React.ReactNode }) => {
  const { isOpen, setIsOpen } = useSelectContext();

  return (
    <button onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen}>
      {children}
    </button>
  );
};

const SelectContent = ({ children }: { children: React.ReactNode }) => {
  const { isOpen } = useSelectContext();

  if (!isOpen) return null;

  return <div className="select-content">{children}</div>;
};

const SelectItem = ({
  value,
  children,
}: {
  value: string;
  children: React.ReactNode;
}) => {
  const { onChange, setIsOpen } = useSelectContext();

  return (
    <button
      onClick={() => {
        onChange(value);
        setIsOpen(false);
      }}
    >
      {children}
    </button>
  );
};

// Usage
<Select value={selected} onValueChange={setSelected}>
  <SelectTrigger>Choose option</SelectTrigger>
  <SelectContent>
    <SelectItem value="react">React</SelectItem>
    <SelectItem value="vue">Vue</SelectItem>
    <SelectItem value="svelte">Svelte</SelectItem>
  </SelectContent>
</Select>;

Discriminated Unions for Variants

Type-safe variant props:

type ButtonVariant =
  | { variant: 'primary'; destructive?: never }
  | { variant: 'destructive'; destructive: true }
  | { variant: 'secondary'; destructive?: never };

interface BaseButtonProps {
  size?: 'sm' | 'md' | 'lg';
  disabled?: boolean;
  children: React.ReactNode;
}

type ButtonProps = BaseButtonProps & ButtonVariant;

const Button = ({ variant, destructive, size = 'md', ...props }: ButtonProps) => {
  const className = `btn btn-${variant} btn-${size}`;

  return <button className={className} {...props} />;
};

// Type-safe usage
<Button variant="primary" />           // ✅
<Button variant="destructive" destructive /> // ✅
<Button variant="primary" destructive />     // ❌ Type error

Generic Components

Build flexible data components:

interface DataTableProps<T> {
  data: T[];
  columns: Array<{
    key: keyof T;
    header: string;
    render?: (value: T[keyof T], item: T) => React.ReactNode;
  }>;
  onRowClick?: (item: T) => void;
}

const DataTable = <T extends Record<string, any>>({
  data,
  columns,
  onRowClick,
}: DataTableProps<T>) => {
  return (
    <table>
      <thead>
        <tr>
          {columns.map((col) => (
            <th key={String(col.key)}>{col.header}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, index) => (
          <tr key={index} onClick={() => onRowClick?.(item)}>
            {columns.map((col) => (
              <td key={String(col.key)}>
                {col.render
                  ? col.render(item[col.key], item)
                  : String(item[col.key])}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

// Usage with full type safety
interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user";
}

<DataTable<User>
  data={users}
  columns={[
    { key: "name", header: "Name" },
    { key: "email", header: "Email" },
    {
      key: "role",
      header: "Role",
      render: (role) => (
        <Badge variant={role === "admin" ? "primary" : "secondary"}>
          {role}
        </Badge>
      ),
    },
  ]}
  onRowClick={(user) => navigate(`/users/${user.id}`)}
/>;

Key Takeaways

  1. Polymorphic components provide flexibility without sacrificing type safety
  2. Compound patterns create intuitive APIs for complex components
  3. Discriminated unions prevent invalid prop combinations
  4. Generic components enable reusability across data types
  5. Context + hooks encapsulate complex state logic

These patterns enable building component libraries that are both powerful and pleasant to use, with TypeScript providing guardrails for correct usage.