A wild problem appears
During a project I stumbled over a problem – like we always do, right?
I had a component in Astro that returned an anchor tag. At one point, I needed that same UI inside of a form. That is, I needed a submit button. How could I create a component that could be two different tags?
I obsessed over this, nerded-out over it, and managed to solve the problem.
For that specific use case, I discovered – spoiler alert – that it was not the best approach. But I learned one or two things about TypeScript along the way, and here I am to share them with you.
Follow me down this rabbit hole, and let’s explore the structure of components.
I am going to use Astro in all of the examples, there could be some syntax differences when applying these techniques in other frameworks. But what is important here is the mental model over the syntax.
What is a component, anyway?
When working with Astro, React or any other web development framework I know of, you will want to use components. Components are reusable structures that contain all the information for that specific element, like its logic and its styling.
For example, without using a component structure we would need something like this to create a container:
.container{
width: min(90%, 80rem);
margin-inline: auto;
}
Here we have a class called container. A developer would need to add that class to every element they wanted to be a container.
That is prone to mistakes, a.k.a. the human-factor.
We don’t want that, right?
One way of solving that is creating a component. This example would be inside of a Container.astro file.
<div class="container"><slot></div>
<style>
.container{
width: min(90%, 80rem);
margin-inline: auto;
}
</style>
That is great, all we need is in the same place, and any developer would just grab that Container and wrap everything inside of it.
But… what if we wanted the tag to be different? Now we don’t want a div, we actually need a section, for example.
Well, of course you could always add the div within that other tag. But let’s try and make our container component more flexible.
All we need to do is create a prop for the tag, and pass that to a variable. Notice that the variable must be capitalized.
---
type Props = {
tag = “div” | “section” | “main”;
}
const {tag} = Astro.props;
const TagName = tag;
---
<TagName class="container"><slot></ TagName >
<style>
.container{
width: min(90%, 80rem);
margin-inline: auto;
}
</style>
And that’s all! Our container now can be called as a div, as a section, or as the main tag. Now you can create projects with better HTML Semantics.
Let’s dive into another example. We re going to create a title component. The challenge here is allowing the user to declare the heading level: h1, h2, h3, etc.
In our Title.astro file we have:
---
type Props = {
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
};
const {tag} = Astro.props;
const TagName = tag;
---
<TagName class="title"><slot></TagName >
<style>
.title {
margin-block-end: 0.75em;
}
</style>
And that’s all! We have a Title component that can be of any heading level. But right now it is not very flexible style-wise. It can only have one class, and it doesn’t change, it doesn’t matter if it is an h1 or an h4.
One approach for that would be passing props that would broaden our styling possibilities.
---
type Props = {
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
size?: “big” | “small”;
align?: “center” | “left”;
};
const {tag, size="small", align="center"} = Astro.props;
const TagName = tag;
---
<TagName class:list={[
"title",
size,
align
]} >
<slot>
</TagName>
<style>
.title {
margin-block-end: 0.75em;
}
.small{
font-size: 1.25rem
}
.big{
font-size: 2rem
}
.center{
text-align: center
}
.left{
text-align: left
}
</style>
This is awesome and gives us many possibilities, but what if we want to add extra flexibility to that component? What if we want to allow classes to be passed to it, that can extend the core styling of the component? Let’s say one specific heading needs to be red, and another one needs to be underlined. We don’t want to add props for all those possibilities, right?
Well, we can allow that by adding a classList prop, that will add external classes to that instance of our component.
---
type Props = {
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
size?: "big" | "small";
align?: "center" | "left";
classList?: string[];
};
const {tag, size="small", align="center", classList=[]} = Astro.props;
const TagName = tag;
---
<TagName class:list={[
"title",
size,
align,
…classList
]} >
<slot>
</TagName >
<style>
(...)
</style>
That solves our problems style-wise, but what if we needed to add an id, or an ARIA attribute? We would get an error.
To deal with that we can use rest parameters in our props, and an index signature in our type.
---
type Props = {
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
size?: "big" | "small";
align?: "center" | "left";
classList?: string[];
[index: string]: string | string[];
};
const {
tag,
size = "small",
align = "center",
classList = [],
...rest
} = Astro.props;
const TagName = tag;
---
<TagName
class:list={["title", size, align, ...classList]}
{...rest}
>
<slot />
</TagName>
<style>
(...)
</style>
Note that we need to pass [index: string]: string | string[]
in our type. That allows us to use any string as a prop. We have to add all the types we have to it, otherwise it will make that key invalid. Here in this example, if we had [index: string]: string
our classList
would be invalid, because it is an array of strings, not a single string.
We must also use the rest parameter in the props destructuring, as seen in ...rest
in our example. What that is doing is grabbing all of the elements in the destructuring assignment that were not declared before.
After doing that we need to spread our rest parameters in our attributes, as seen in ...rest
inside of the TagName element.
This will allow us to pass any attribute down to the component.
That is great, right?
Right????
Not really. That way we can pass literally any value to the component, even invalid ones, like passing an inexistent value.
Luckily, Astro has some types that can helps us achieve flexibility while maintaining code safety: HTMLTag and Polymorphic.
---
import type { HTMLTag, Polymorphic } from "astro/types";
type TagType = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
type Props<TagName extends HTMLTag> = {
size?: "big" | "small";
align?: "center" | "left";
classList?: string[];
} & Polymorphic<{ as: TagName & TagType }>;
const {
as: Tag,
size = "small",
align = "center",
classList = [],
...rest
} = Astro.props;
---
<Tag
class:list={["title", size, align, ...classList]}
{...rest}
>
<slot />
</Tag>
<style>
(...)
</style>
Here we are taking advantage of Astro’s Polymorphic type, that has the "as" key. "as" gets the Generic type we defined in our Props type (TagName
), which extends HTMLTag, that is, it needs to be an existing HTMLTag. I then added the TagType using the intersection operator ({ as: TagName & TagType }
).
The truth is, we didn’t need the HTMLTag type, since our TagType is already a group of HTML Tags. But it didn't hurt using it.
Notice the syntax for declaring a variable while destructuring: declaring {a: Tag} = Astro.props
is the same as declaring {a} = Astro.props
, and then setting const Tag = a
.
With this code structure, we are not allowed to pass an invalid attribute to our component anymore. Hooray!
Where it all began
Now we finally get to the problem I originally had. When dealing with our Title component, our problems were easier. All heading levels share the same attributes, while an anchor tag and a button tag don’t!
I needed to create a component that could be an anchor or a button, and when the anchor option was chosen, the type attribute should not be available, as well as the href attribute shouldn’t be available to a button.
This is what I came up with before getting to know the Polymorphic type:
---
type Tag = "a" | "button";
type AnchorProps = {
href: string;
};
type ButtonProps = {
type?: "submit" | "button" | "reset";
};
type Props<TagGeneric extends Tag> = {
tag: TagGeneric;
variant?: "primary" | "secondary";
classList?: string[];
[index: string]: string | string[];
} & (TagGeneric extends "a" ? AnchorProps : ButtonProps);
const {
tag,
variant = "primary",
classList = [],
href,
type = "button",
...rest
} = Astro.props;
const Tag = tag;
const baseAttributes = {
classes: ["title", variant, ...classList],
...rest,
};
const anchorAttributes = {
href: href,
};
const buttonAttributes = {
type: type,
};
const attributeBasedOnTag = tag === "a" ? anchorAttributes : buttonAttributes;
const allAttributes = { ...baseAttributes, ...attributeBasedOnTag };
const { classes, ...attributes } = allAttributes;---
<Tag
class:list={[classes]}
{...attributes}
>
<slot />
</Tag>
What is happening here?
I define the type Tag, the AnchorProps and the ButtonProps. Then we have the type for the SharedProps. This structure is already familiar to you based on what we’ve seen up to this point.
What is new here is the conditional structure for types. We are comparing our generic (TagGeneric
), which is defined when the prop tag is passed to the component, and we are extending our props based on that. If it equals “a” - in TypeScript: if it extends “a” - we apply our AnchorProps. Otherwise we apply our ButtonProps.
This is great, but with this structure we still can have inexistent attributes being passed, because of our wildcard [index: string]: string
.
Using the Polymorphic type, we can avoid that kind of problem:
---
import type { Polymorphic } from "astro/types";
type Tag = "a" | "button";
type Props<TagGeneric extends Tag> = {
tag: TagGeneric;
variant?: "primary" | "secondary";
classList?: string[];
} & Polymorphic<{ as: TagGeneric }>;
const {
tag: Tag,
variant = "primary",
classList = [],
href,
type = "button",
...rest
} = Astro.props;
const baseAttributes = {
classes: ["title", variant, ...classList],
...rest,
};
const anchorAttributes = {
href: href,
};
const buttonAttributes = {
type: type,
};
const attributeBasedOnTag = Tag === "a" ? anchorAttributes : buttonAttributes;
const allAttributes = { ...baseAttributes, ...attributeBasedOnTag };
const { classes, ...attributes } = allAttributes;
---
<Tag
class:list={[classes]}
{...attributes}
>
<slot />
</Tag>
That way we can never pass an attribute that doesn’t belong in our component.
Of course, since in this component I am basically sharing styles between anchor tags and buttons, I only needed to create two components: ButtonAsAnchor and ButtonAsButton, and make them share CSS classes.
Sometimes the best solution is the simplest. But taking the longer way may gift us with some more tools to our utility belt.
Guidelines
Wrapping up, here are some general guidelines:
- Avoid props that are protected terms (e.g.: class, style);
- Allow classes to be passed, so your component is extensible;
- Use the rest parameter if you might need to allow other attributes in your component;
- When using the rest parameter, use the index signature feature from TypeScript in your type;
- Check for your framework’s implementation of Polymorphic components;
References:
- https://ishadeed.com/article/styling-the-good-old-button/
- https://docs.astro.build/en/basics/astro-syntax/#dynamic-tags
- https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures
- https://davidwalsh.name/destructuring-alias
- https://docs.astro.build/en/guides/typescript/#polymorphic-type
Top comments (2)
A very thorough writeup of the pros and cons of dynamic components! They are incredibly tricky if you're trying to fit every single solution. But can work very well when just trying to do very specific things!
Great article, Caio! Sometimes it's just not worth it creating a component when their props and attributes are so different, even though the UI looks almost the same. It may make sense to you, the developer who is working on the project, but when you need third parties in your project, it may seem incredibly confusing for them.