Reputation: 1318
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
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
Reputation: 1318
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} />;
}
Update 4 Apr 2024: I stumbled upon a new article: React "as" Prop Using TypeScript.
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>;
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
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
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)
I see no ts errors in this case and have completions for label props like htmlFor
Upvotes: 0