3

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
2
  • Do you want to achieve this without usage of any library? Or you have any component library you want this to be built on? Commented Dec 5, 2022 at 13:07
  • Hi @MalwareMoon, I don't want to use any library, unless it provides only utility types Commented Dec 11, 2022 at 22:26

4 Answers 4

3
+100

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>
    )
}
Sign up to request clarification or add additional context in comments.

16 Comments

Hi, thanks for your answer, but this solution does not support any kind of element. Besides, this is for a component library and I must not have any dependency to the Link component. I'll update the question accordingly.
I understood what you're trying to achieve, i'll try something different
@vcarel I just edited my answer, hope this will fit the requirements
@Nick into the weeds nit pick: { as?: never } is the same as { as?: undefined } When using ? the type of the property is unioned with undefined so you get undefined | never which reduces to undefined. Do both versions work? Sure. But using never in this way creates a false impression about what's going on here. as could be undefined, there is a value we can assign to it, never gives the impression that as is not assignable with anything on that overload which isn't the case.
@Nick, I think I read too quickly last night and forgot a part of ValidElement's definition. So it works at the end... almost. I have to explicitly pass "a" this way if I want event handlers to be correctly typed: <Button<"a"> as="a" onClick={e => console.log(e)}>. But it's okay to do that. Many thanks for your solution!
|
0
const Button = (props) => {

const { title, onClick, href, to } = props;
if (href) {
    return (
        <a href={href}>{title}</a>
    )
}
return (
    <Link to={to} onClick={onClick} >{title}</Link>
)

}

then we can call it like this for button

<Button 
  title="Click me"
  onClick={()=>{alert("this is button click")})
/>

for Anchor

<Button 
  title="this is Anchor"
  href="https://www.google.com"
/>

for Link

<Button 
  title="this is Link"
  to-"/path/subpath"
/>

1 Comment

Thanks for your answer, but this is not a typescript solution. Also, I would like to support any kind of tag, not only anchors, buttons, react-router Links...
0

This achieves what you want with all the types intact, except for "fallback to 'button' element" which should be trivial to implement.

import { ComponentProps, ComponentType, forwardRef } from "react";

type Tags = keyof JSX.IntrinsicElements;

type Props<T> = T extends Tags
  ? {
      as: T;
    } & JSX.IntrinsicElements[T]
  : T extends ComponentType<any>
  ? {
      as: T;
    } & ComponentProps<T>
  : never;

function ButtonInner<T>({ as, ...rest }: Props<T>, ref: React.ForwardedRef<T>) {
  const Component = as;

  return <Component ref={ref} {...rest} />;
}

const Button = (forwardRef(ButtonInner) as unknown) as <T>(
  props: Props<T>
) => ReturnType<typeof ButtonInner>;

export default Button;

You can try it out in the playground: https://codesandbox.io/s/dry-silence-uybwmp?file=/src/App.tsx

I've added a bunch of examples in the App.tsx

2 Comments

Thanks, but this is a variant my solution... where event handlers are not typed correctly. In your codesandbox, you can see that onClick arguments are inferred as "any"
@vcarel not sure what are you talking about. All the types are correctly resolved in my example. That is specifically what I focused on when creating this solution. Here are screenshots of the onClick props on different buttons: imgur.com/a/fJkO4Lw
0

Sure, I have several UI libraries published to a private registry on azure for my org (using TurboRepos). This is a ButtonAnchor Hybrid Component used in multiple codebases that truly segregates ButtonHTMLAttributes from AnchorHTMLAttributes -- but you can extend the logic with additional unions etc to achieve the desired polymorphic component outcome.

There is one definition I'll include before the code from the file for clarity

export namespace UI {
  export module Helpers {
    export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
   
    /* Provides a truly mutually exclusive type union */
    export type XOR<T, U> = T | U extends object
      ? (Without<T, U> & U) | (Without<U, T> & T)
      : T | U;
  }
}

However, for simplicity you can pull those two types out of the Namespace.module.[Type] chaining used in this ui component repo.

export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

/* Provides a truly mutually exclusive type union */
export type XOR<T, U> = T | U extends object
  ? (Without<T, U> & U) | (Without<U, T> & T)
  : T | U;

Here is the file:

import type { FC, ButtonHTMLAttributes, AnchorHTMLAttributes, JSXElementConstructor } from "react";
import cn from "clsx";
import LoadingDots from "../LoadingDots";
import UI from "../../typedefs/namespace";

export type ButtonAnchorXOR = UI.Helpers.XOR<"a", "button">;
/**
 * component types allowed by the (Button | Anchor) IntrinsicElements
 */
export type ButtonAnchorComponentType =
  | "button"
  | "a"
  | JSXElementConstructor<
      React.DetailedHTMLProps<
        ButtonHTMLAttributes<HTMLButtonElement>,
        HTMLButtonElement
      >
    >   | JSXElementConstructor<
    React.DetailedHTMLProps<
      AnchorHTMLAttributes<HTMLAnchorElement>,
      HTMLAnchorElement
    >
  >;;

/**
 * Base props of the (Button | Anchor) components.
 */
export interface ButtonAnchorProps<
  C extends ButtonAnchorComponentType = ButtonAnchorXOR
> {
  href?: string;
  className?: string;
  variant?: "primary" | "secondary" | "ghost" | "violet" | "black" | "white";
  size?: "sm" | "md" | "lg";
  active?: boolean;
  Component?: C;
  width?: string | number;
  loading?: boolean;
}

/**
 * The HTML props allowed by the (Button | Anchor) components.
 * These props depend on the used component type (C = "a" | "button").
 */
export type ButtonAnchorHTMLType<
  C extends ButtonAnchorComponentType = ButtonAnchorXOR
> = C extends "a"
  ? AnchorHTMLAttributes<HTMLAnchorElement>
  : ButtonHTMLAttributes<HTMLButtonElement>;

export type ButtonAnchorFC<
  C extends ButtonAnchorComponentType = ButtonAnchorXOR
> = FC<ButtonAnchorHTMLType<C> & ButtonAnchorProps<C>>;

export type ButtonType = <C extends ButtonAnchorComponentType = "button">(
  ...args: Parameters<ButtonAnchorFC<C>>
) => ReturnType<ButtonAnchorFC<C>>;

export type AnchorType = <C extends ButtonAnchorComponentType = "a">(
  ...args: Parameters<ButtonAnchorFC<C>>
) => ReturnType<ButtonAnchorFC<C>>;

export type ButtonAnchorConditional<
  T extends ButtonAnchorXOR = ButtonAnchorXOR
> = T extends "a"
  ? AnchorType
  : T extends "button"
  ? ButtonType
  : UI.Helpers.XOR<ButtonType, AnchorType>;

const Button: ButtonAnchorFC<"button"> = props => {
  const {
    width,
    active,
    children,
    variant = "primary",
    Component = "button",
    loading = false,
    style = {},
    disabled,
    size = "md",
    className,
    ...rest
  } = props;

  const variants = {
    primary:
      "text-background bg-success border-success-dark hover:bg-success/90 shadow-[0_5px_10px_rgb(0,68,255,0.12)]",
    ghost: "text-success hover:bg-[rgba(0,68,255,0.06)]",
    secondary:
      "text-accents-5 bg-background border-accents-2 hover:border-foreground hover:text-foreground",
    black:
      "bg-foreground text-background border-foreground hover:bg-background hover:text-foreground",
    white: "bg-background text-foreground border-background hover:bg-accents-1",
    violet: "text-background bg-violet border-violet-dark hover:bg-[#7123be]"
  };

  const sizes = {
    sm: "h-8 leading-3 text-sm px-1.5 py-3",
    md: "h-10 leading-10 text-[15px]",
    lg: "h-12 leading-12 text-[17px]"
  };

  const rootClassName = cn(
    "relative inline-flex items-center justify-center cursor pointer no-underline px-3.5 rounded-md",
    "font-medium outline-0 select-none align-middle whitespace-nowrap",
    "transition-colors ease-in duration-200",
    variant !== "ghost" && "border border-solid",
    variants[variant],
    sizes[size],
    { "cursor-not-allowed": loading },
    className
  );

  return (
    <Component
      aria-pressed={active}
      data-variant={variant}
      className={rootClassName}
      disabled={disabled}
      style={{
        width,
        ...style
      }}
      {...rest}>
      {loading ? (
        <i className='m-0 flex'>
          <LoadingDots />
        </i>
      ) : (
        children
      )}
    </Component>
  );
};

const Anchor: ButtonAnchorFC<"a"> = props => {
  const {
    width,
    active,
    children,
    variant = "primary",
    Component = "a",
    loading = false,
    style = {},
    size = "md",
    className,
    ...rest
  } = props;

  const variants = {
    primary:
      "text-background bg-success border-success-dark hover:bg-success/90 shadow-[0_5px_10px_rgb(0,68,255,0.12)]",
    ghost: "text-success hover:bg-[rgba(0,68,255,0.06)]",
    secondary:
      "text-accents-5 bg-background border-accents-2 hover:border-foreground hover:text-foreground",
    black:
      "bg-foreground text-background border-foreground hover:bg-background hover:text-foreground",
    white: "bg-background text-foreground border-background hover:bg-accents-1",
    violet: "text-background bg-violet border-violet-dark hover:bg-[#7123be]"
  };

  const sizes = {
    sm: "h-8 leading-3 text-sm px-1.5 py-3",
    md: "h-10 leading-10 text-[15px]",
    lg: "h-12 leading-12 text-[17px]"
  };

  const rootClassName = cn(
    "relative inline-flex items-center justify-center cursor pointer no-underline px-3.5 rounded-md",
    "font-medium outline-0 select-none align-middle whitespace-nowrap",
    "transition-colors ease-in duration-200",
    variant !== "ghost" && "border border-solid",
    variants[variant],
    sizes[size],
    { "cursor-not-allowed": loading },
    className
  );

  return (
    <Component
      aria-pressed={active}
      data-variant={variant}
      className={rootClassName}
      style={{
        width,
        ...style
      }}
      {...rest}>
      {loading ? (
        <i className='m-0 flex'>
          <LoadingDots />
        </i>
      ) : (
        children
      )}
    </Component>
  );
};

const PolyMorphicComponent = <T extends ButtonAnchorXOR = ButtonAnchorXOR>({
  props,
  type
}: {
  props?: ButtonAnchorConditional<T>
  type: T;
}) => {
  switch (type) {
    case "a":
      return <Anchor Component="a" {...props as AnchorType} />;
    case "button":
      return <Button Component='button' {...props as ButtonType} />;
    default:
      return <>{`The type property must be set to either "a" or "button"`}</>;
  }
};

Anchor.displayName = "Anchor";
Button.displayName = "Button";

export default PolyMorphicComponent;


1 Comment

Your solution supports only a known list of tags, which is not what I need. I would like to write <Button as={AnyComponent}>

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.