Button or A
I like to be able to abstract away the complexity of implementation and focus on the use. When this comes to buttons or anchors, I want my developers to be able to use the component that looks like a button or link and not worry about what html tag it renders.
Polymorphic components
This is called polymorphism when we're referring to components. It allows the underlying html tag to be different based on various conditions, like a prop.
Some components use the as
prop where you can specify which tag you want the component to output. This is ok but requires the developer to pick the right option for the situation. I wanted to see if it could pick the right one based on context.
href is the key
Thought there may be other subtle properties that differentiate a button from an anchor, the href
prop is the one we care about.
Basically, if an href
is specified then the rendered HTML should be an <a>
, otherwise it's a <button>
. The component you pick, whether <Button>
or <Link>
should always look like a button or link, even if the underlying HTML is different.
TypeScript
I was fairly new to TypeScript when I first built this component so I didn't fully understand what was going on but copied from other implementations to get it to work.
Now I have a deeper understanding and want to look at it again.
The key feature that makes this work is the type guard. This is a function that returns a type predicate. I'll get into these in a bit.
ButtonOrA
I don't know that this is the best naming of the component, but I'm in favor of using names that are as descriptive as possible as to what the component does.
This will act as the based component for a number of higher level components including:
<Button>
<Link>
<MenuButton>
<FooterButton>
Essentially any component that has a specific look and style but you want it to choose between a <button>
or a <a>
when it renders. I don't want to have to duplicate that logic in all those components.
On to the code
Here is the code for this component that we'll be going over:
import { Ref, forwardRef } from 'react';
// Button props
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
href?: undefined;
};
// Anchor props
type AnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href?: string;
disabled?: boolean;
};
export type ButtonOrAProps = ButtonProps | AnchorProps;
export type ButtonOrARef = HTMLButtonElement | HTMLAnchorElement;
const hasHref = (props: ButtonOrAProps): props is AnchorProps =>
'href' in props && props.href !== undefined;
const ButtonOrA = forwardRef<ButtonOrARef, ButtonOrAProps>((props, ref) => {
// anchor render
if (hasHref(props)) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- a tag can't accept disabled prop
const { disabled, ...restProps } = props;
// eslint-disable-next-line jsx-a11y/anchor-has-content -- this is a base comopnent, accessibility should be handled by the parent
return <a {...restProps} ref={ref as Ref<HTMLAnchorElement>} />;
}
return <button {...props} ref={ref as Ref<HTMLButtonElement>} />;
});
ButtonOrA.displayName = 'ButtonOrA';
export { ButtonOrA };
Let's go over this.
Prop types
// Button props
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
href?: undefined;
};
// Anchor props
type AnchorProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href?: string;
disabled?: boolean;
};
We need to define the prop types for both the Button and the Anchor. We define the href
for both so that we have something for our type guard to test against.
The disabled
prop on AnchorProps
was added since our <Button>
component may look like a button but render as an <a>
. An anchor doesn't support the disabled attribute so we need to add it. We then remove it before we apply the props to the <a>
and use it just for styling purposes.
Type guard
const hasHref = (props: ButtonOrAProps): props is AnchorProps =>
'href' in props && props.href !== undefined;
This is a function that returns a type predicate. A type predicate is basically a way for TypeScript to use the result of a function to determine the type. In this case we're testing if href
is in the props and telling TypeScript that means we should use the AnchorProps
type.
Render depending on the result of hasHref
It's mostly straightforward from that point. We test the result of hasHref
and render different output based on the result. Types all match up and we're good to go!
Deep TypeScript
This was a hard concept to get early on in my TypeScript career. It's still a bit tricky but getting easier all the time. Hopefully this helps you understand a little bit of this difficult topic.