Gabin
Gabin

Reputation: 1318

How can I implement a "as" prop with TypeScript while passing down the props?

I am building a library of components and I need some of them to have a customizable tag name. For example, sometimes what looks like a <button> is actually a <a>. So I would like to be able to use the button component like so:

<Button onClick={onClick}>Click me!</Button>
<Button as="a" href="/some-url">Click me!</Button>

Ideally, I would like the available props to be inferred based on the "as" prop:

// Throws an error because the default value of "as" is "button",
// which doesn't accept the "href" attribute.
<Button href="/some-url">Click me!<Button>

We might need to pass a custom component as well:

// Doesn't throw an error because RouterLink has a "to" prop
<Button as={RouterLink} to="/">Click me!</Button>

Here's the implementation, without TypeScript:

function Button({ as = "button", children, ...props }) {
  return React.createElement(as, props, children);
}

So, how can I implement a "as" prop with TypeScript while passing down the props?

Note: I am basically trying to do what styled-components does. But we are using CSS modules and SCSS so I can't afford adding styled-components. I am open to simpler alternatives, though.

Upvotes: 24

Views: 10976

Answers (4)

Ali Akbar Azizi
Ali Akbar Azizi

Reputation: 3506

To have it work with the correct ref type and also with forwardRef, you can use this.

Type Definition

type PropsWithAs<
  Tag extends ElementType,
  Overrides = Record<string, never>,
> = Omit<ComponentProps<Tag>, keyof Overrides> &
  Overrides &
  RefAttributes<ElementRef<Tag>> & { as?: Tag };

Without forwardRef

type ComponentWithoutRefProps<Tag extends ElementType> = PropsWithAs<
  Tag,
  { testWithoutRef: boolean }
>;

function ComponentWithoutRef<Tag extends ElementType = "button">(
  props: ComponentWithoutRefProps<Tag>,
) {
  return <div>test</div>;
}

With forwardRef

type ComponentWithRefProps<Tag extends ElementType> = PropsWithAs<
  Tag,
  { testRef: boolean }
>;

const ComponentWithRef = forwardRef(function ComponentWithRef<
  Tag extends ElementType = "div",
>(props: ComponentWithRefProps<Tag>, ref: React.Ref<ElementRef<Tag>>) {
  return <div>test</div>;
}) as <TTag extends ElementType = "button">(
  props: ComponentWithRefProps<TTag>,
) => JSX.Element;

Here is the playground.

Upvotes: 0

Gabin
Gabin

Reputation: 1318

New answer

I recently came across Iskander Samatov's article React polymorphic components with TypeScript in which they share a more complete and simpler solution:

import * as React from "react";

interface ButtonProps<T extends React.ElementType> {
  as?: T;
  children?: React.ReactNode;
}

function Button<T extends React.ElementType = "button">({
  as,
  ...props
}:
  ButtonProps<T>
  & Omit<React.ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>
) {
  const Component = as || "button";
  return <Component {...props} />;
}

Typescript playground

Update 4 Apr 2024: I stumbled upon a new article: React "as" Prop Using TypeScript.

Old answer

I spent some time digging into styled-components' types declarations. I was able to extract the minimum required code, here it is:

import * as React from "react";
import { Link } from "react-router-dom";

type CustomComponentProps<
  C extends keyof JSX.IntrinsicElements | React.ComponentType<any>,
  O extends object
> = React.ComponentPropsWithRef<
  C extends keyof JSX.IntrinsicElements | React.ComponentType<any> ? C : never
> &
  O & { as?: C };

interface CustomComponent<
  C extends keyof JSX.IntrinsicElements | React.ComponentType<any>,
  O extends object
> {
  <AsC extends keyof JSX.IntrinsicElements | React.ComponentType<any> = C>(
    props: CustomComponentProps<AsC, O>
  ): React.ReactElement<CustomComponentProps<AsC, O>>;
}

const Button: CustomComponent<"button", { variant: "primary" }> = (props) => (
  <button {...props} />
);

<Button variant="primary">Test</Button>;
<Button variant="primary" to="/test">
  Test
</Button>;
<Button variant="primary" as={Link} to="/test">
  Test
</Button>;
<Button variant="primary" as={Link}>
  Test
</Button>;

TypeScript playground

I removed a lot of stuff from styled-components which is way more complex than that. For example, they have some workaround to deal with class components which I removed. So this snippet might need to be customized for advanced use cases.

Upvotes: 48

Liam Marin
Liam Marin

Reputation: 21

Based on Gabin's answer, and with a little inspiration from the Chakra UI source code, I wrote my own generic version which allows you to use the as prop anywhere you want without repeating too much boilerplate:

import { ComponentPropsWithoutRef, ElementType } from "react";

export type As = ElementType;

export type MergeWithAs<Component extends As, Props extends object = {}> = Omit<
  Props,
  "as"
> & { as?: Component };

export type ComponentPropsWithAs<
  Component extends As,
  Props extends object = {},
> = Omit<
  ComponentPropsWithoutRef<Component>,
  keyof MergeWithAs<Component, Props>
> &
  MergeWithAs<Component, Props>;

Here's an example of it in action:

import clsx from "clsx";

import { As, ComponentPropsWithAs } from "../types/ComponentPropsWithAs";

interface TextContainerProps {
  className?: string;
}

export default function TextContainer<Component extends As>({
  as,
  className,
  ...props
}: ComponentPropsWithAs<Component, TextContainerProps>) {
  const Component = as ?? "div";
  return (
    <Component
      {...props}
      className={clsx("max-w-[70ch] mx-auto px-4", className)}
    />
  );
}

I haven't been able to get it working with React.forwardRef, but y'all are welcome to try.

Upvotes: 2

Vlad Rose
Vlad Rose

Reputation: 189

I found that you can make the same thing with JSX.IntrinsicElements. I have Panel element:

export type PanelAsKeys = 'div' | 'label'
export type PanelAsKey = Extract<keyof JSX.IntrinsicElements, PanelAsKeys>
export type PanelAs<T extends PanelAsKey> = JSX.IntrinsicElements[T]
export type PanelAsProps<T extends PanelAsKey> = Omit<PanelAs<T>, 'className' | 'ref'>

I omitted native types like ref and className because i have my own types for these fields

And this how props will look look like

export type PanelProps<T extends PanelAsKey = 'div'> = PanelAsProps<T> & {}

const Panel = <T extends PanelAsKey = 'div'>(props: PanelProps<T>) => {}

Then you can use React.createElement

return React.createElement(
  as || 'div',
  {
    tabIndex: tabIndex || -1,
    onClick: handleClick,
    onFocus: handleFocus,
    onBlur: handleBlur,
    onMouseEnter: handleMouseEnter,
    onMouseLeave: handleMouseLeave,
    'data-testid': 'panel',
    ...rest
  },
  renderChildren)

enter image description here

I see no ts errors in this case and have completions for label props like htmlFor

Upvotes: 0

Related Questions