Typing the click handler when component is generic (polymorphic react component)

Viewed 211

This is my component

interface Props<C extends React.ElementType> {
  as?: C;
  children: React.ReactNode;
  size?: "mini" | "small" | "medium" | "large";
  onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}

type ButtonProps<C extends React.ElementType> = Props<C> &
  Omit<React.ComponentPropsWithRef<C>, keyof Props<C>>;

export const Button = <C extends React.ElementType = "button">({
  children,
  as,
  size = "medium",
  onClick,
  ...restProps
}: ButtonProps<C>) => {
  const Component = as || "button";

  return (
    <Component {...restProps} className={`button-${size}`} onClick={onClick}>
      {children}
    </Component>
  );
};

I can use the component like this:

<Button as="a" onClick={(e) => console.log(e)}>Anchor button</a>

The problem is that the type for the click handler says the event is of type React.MouseEvent<HTMLButtonElement, MouseEvent>, but it isn't. It should be HTMLAnchorElement in this case.

How can I provide the correct type? Ideally I'd somehow use C to get the correct HTMLXyzElement

Here's a TypeScript Playground link where you can play around with the code if you want

2 Answers
import React, { useRef } from "react";

type InferElement<T> =
  T extends keyof JSX.IntrinsicElements
  ? JSX.IntrinsicElements[T] extends React.DetailedHTMLProps<React.AnchorHTMLAttributes<any>, infer Elem>
  ? Elem
  : never
  : HTMLElement

type Result = InferElement<'a'> // HTMLAnchorElement

interface Props<C extends React.ElementType> {
  as?: C;
  children: React.ReactNode;
  size?: "mini" | "small" | "medium" | "large";
  onClick?: (event: React.MouseEvent<InferElement<C>, MouseEvent>) => void;
}

type ButtonProps<C extends React.ElementType> = Props<C> &
  Omit<React.ComponentPropsWithRef<C>, keyof Props<C>>;

export const Button = <C extends React.ElementType = "button">({
  children,
  as,
  size = "medium",
  onClick,
  ...restProps
}: ButtonProps<C>) => {
   const Component: React.ElementType = as || "button";


  return (
    <Component {...restProps} className={`button-${size}`} onClick={onClick}>
      {children}
    </Component>
  );
};

const Link = ({ to, children }: { to: string; children: React.ReactNode }) => {
  return <a href={to}>{children}</a>;
};

export const ButtonUser = () => {
  return (
    <div>
      <Button size="mini" disabled>
        Button text
      </Button>
      <Button as="a" size="mini" href="test" onClick={(e) => console.log(e)}>
        Button text
      </Button>
      <Button as={Link} to="/" size="mini">
        Button text
      </Button>
    </div>
  );
};

const jsx = <Button as="a" onClick={(e) => console.log(e)}>Anchor button</Button>

Since JSX.IntrinsicElements is just a big map of html elements, You can infer html element name from JSX.IntrinsicElements and use it for callback argument. see InferElement util

You can use HTMLElement instead of HTMLButtonElement

Related