Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I create a React polymorphic component with Typescript?

I would like to create a polymorphic button that could actually be a button, an anchor or a router link.

For instance:

<Button onClick={e => console.log("click", e)}>A button</Button>

<Button as="a" href="https://somewhere-external.com" rel="noopener noreferrer" target="_blank">
  An anchor
</Button>

<Button as={Link} to="/some_page">
  A Link
</Button>

I have read many articles, like this one, but I find the solutions overly complicated, especially when it comes to support forwardRef.

I'm looking for something simple to use & easy to understand.

Edit: This is for a component library, so I want to avoid any dependency to <Link> (provided by react-router or similar libs). Besides, I should be able to support other components, like headless-ui <Popover.Button>

I had in mind a solution like below, but the event handlers are all typed against HTMLButtonElement, which is obviously wrong.

/* Types file */

export type PolymorphicProps<
  OwnProps,
  As extends ElementType,
  DefaultElement extends ElementType
> = OwnProps &
  (
    | (Omit<ComponentProps<As>, "as"> & { as?: As })
    | (Omit<ComponentProps<As>, "as"> & { as: As })
    | (Omit<ComponentProps<DefaultElement>, "as"> & { as?: never })
  )


/* Component file */

const defaultElement = "button"

type OwnProps = {}

type Props<As extends ElementType = typeof defaultElement> = PolymorphicProps<
  OwnProps,
  As,
  typeof defaultElement
>

const Button = <As extends ElementType = typeof defaultElement>(
  { as, children, ...attrs }: Props<As>,
  ref: ForwardedRef<ComponentProps<As>>
) => {
  const Component = as || defaultElement
  return (
    <Component ref={ref} {...attrs}>
      {children}
    </Component>
  )
}

export default forwardRef(Button) as typeof Button
like image 209
vcarel Avatar asked Apr 20 '26 07:04

vcarel


1 Answers

This is what i came up with:

type ValidElement<Props = any> = keyof Pick<HTMLElementTagNameMap, 'a' | 'button'> | ((props: Props) => ReactElement)

function PolyphormicButton <T extends ValidElement>({ as, ...props }: { as: T } & Omit<ComponentPropsWithoutRef<T>, 'as'>): ReactElement;
function PolyphormicButton ({ as, ...props }: { as?: undefined } & ComponentPropsWithoutRef <'button'>): ReactElement;
function PolyphormicButton <T extends ValidElement>({
  as,
  ...props
}: { as?: T } & Omit<ComponentPropsWithoutRef<T>, "as">) {
  const Component = as ?? "button"

  return <Component {...props} />
}

What am i doing here?

  • Declare a ValidElement type to force the type of as to be a valid type, in this case either:
    • A value from HTMLElementTagNameMap
    • A general component
  • (optional) Declare function overloads to accept or not the as parameter while keeping default props
  • Declare the function body that renders the corresponding html element with it's props

Of course typescript and tslint are used and only element's own props are viewed.

Usage:

const Home = () => {
    const href = "whatever"

    return (
        <PolymorphicButton>just a button</PolymorphicButton>
        <PolymorphicButton as="a" href={href}>an anchor</PolymorphicButton>
        <PolymorphicButton as={Link} to={href}>a Link component</PolymorphicButton>
    )
}
like image 198
Nick Avatar answered Apr 22 '26 22:04

Nick