Jump to content

The native element extension pattern

An introduction

Over the past few years, while working with TypeScript and React, I started developing this pattern, which has proven to be easy to maintain while providing an excellent and predictable DX. The gist of it is basically making all my components extend the underlying HTML instead of rewriting its properties. To illustrate this, let me go over an example and build from there.

As usual, let’s start with something simple

Say we have to build an Anchor component. This component only wraps the HTML a element but adds an aside boolean property to open that anchor in a new window. While we are on it, let’s add rel="noopener noreferrer" for extra safety:

Anchor.tsx
1
import type { PropsWithChildren } from "react";
2
3
export type AnchorProps = {
4
readonly aside?: boolean;
5
readonly href?: string;
6
};
7
8
export const Anchor = ({
9
aside = false,
10
children,
11
href,
12
}: PropsWithChildren<AnchorProps>) => (
13
<a
14
href={href}
15
rel="noopener noreferrer"
16
target={aside ? "_blank" : undefined}
17
>
18
{children}
19
</a>
20
);

Accessing other properties

The main problem with creating components like this is that if we need to access other properties of the underlying HTML element, we must go back to our component and change it again. Let’s say we want to let the consumer set their own rel, so if they want to set it to "prefetch", they can. We need to go back and make some changes:

Anchor.tsx
1
import type { PropsWithChildren } from "react";
2
3
export type AnchorProps = {
4
readonly aside?: boolean;
5
readonly href?: string;
6
readonly rel?: string;
7
};
8
9
export const Anchor = ({
10
aside = false,
11
children,
12
href,
13
rel = "noopener noreferrer",
14
}: PropsWithChildren<AnchorProps>) => (
15
<a href={href} rel={rel} target={aside ? "_blank" : undefined}>
16
{children}
17
</a>
18
);

Next day, we need to let consumers change the target as well, so…

Anchor.tsx
1
import type { PropsWithChildren } from "react";
2
3
export type AnchorProps = {
4
readonly aside?: boolean;
5
readonly href?: string;
6
readonly rel?: string;
7
readonly target?: string;
8
};
9
10
export const Anchor = ({
11
aside = false,
12
children,
13
href,
14
rel = "noopener noreferrer",
15
target = aside ? "_blank" : undefined,
16
}: PropsWithChildren<AnchorProps>) => (
17
<a href={href} rel={rel} target={target}>
18
{children}
19
</a>
20
);

What if we want to let consumers set className? Do we keep going forever with every property of a?

The native element extension pattern

Here’s where the protagonist of this article comes to our rescue. The idea is to make our Anchor component only add properties on top of a and maybe set some new default values, but everything else should come straight from a. Here’s how we do it:

Anchor.tsx
1
export type AnchorProps = JSX.IntrinsicElements["a"] & {
2
readonly aside?: boolean;
3
};
4
5
export const Anchor = ({
6
aside = false,
7
rel = "noopener noreferrer",
8
target = aside ? "_blank" : undefined,
9
...props
10
}: AnchorProps) => <a {...{ ...props, aside, rel, target }} />;

The secret sauce we use here is the JSX.IntrinsicElements["a"] type. JSX.IntrinsicElements is the type used by TypeScript to give us auto-completion of properties of HTML elements in JSX. That object has this shape:

1
type IntrinsicElements = {
2
[TagName]: TagProperties;
3
};

So when we do JSX.IntrinsicElements["a"] we get all the properties of a, and then we add aside to it. Now Anchor effectively has all the properties of a, with way less code needed.

If you’re lazy like me, then you can create a type alias for that type like this:

ExtendedTag.ts
1
type ExtendedTag<
2
TagName extends keyof JSX.IntrinsicElements,
3
Extension extends Record<string, unknown>,
4
> = JSX.IntrinsicElements[TagName] & Extension;

And then we use it like this:

Anchor.tsx
1
import type { ExtendedTag } from "./ExtendedTag.js";
2
3
export type AnchorProps = ExtendedTag<"a", { readonly aside?: boolean }>;
4
5
export const Anchor = ({
6
aside = false,
7
rel = "noopener noreferrer",
8
target = aside ? "_blank" : undefined,
9
...props
10
}: AnchorProps) => <a {...{ ...props, aside, rel, target }} />;

Nested elements

This pattern also makes things easier to customize from the consumer side for nested elements. Let’s say we have an AnchorIcon element, which is our previous Anchor element, wrapping an Icon element (to keep this short, pretend is the same as Anchor but wrapping an svg instead of an a). We can follow the pattern like this:

AnchorIcon.tsx
1
import { Anchor, type AnchorProps } from "./Anchor.js";
2
import { Icon, type IconProps } from "./Icon.js";
3
4
export type AnchorIconProps = AnchorProps & {
5
readonly iconProps?: IconProps;
6
};
7
8
export const AnchorIcon = ({
9
children,
10
iconProps,
11
...props
12
}: AnchorIconProps) => (
13
<Anchor {...props}>{children ?? <Icon {...iconProps} />}</Anchor>
14
);

And now, when we use it, we have access to all the properties of Anchor, but we also have access to all the properties of Icon, so if we want to set the className of Icon, we don’t need to go back to the element and add iconClass or something like that, we can do this instead:

1
<AnchorIcon iconProps={{ className: "error" }} />

This can be applied to as many nested components as we need, but let’s keep in mind that if you’re nesting many elements, that might be a sign that you need to split that component into smaller ones.

Closing thoughts

This is one of those patterns that requires a small initial extra effort, but it ultimately lowers maintenance costs and provides a better developer experience. As always with my pattern articles, I invite the readers of this article to try it out in one of their projects and tell me how it goes!