Reactでas propsを実現する
例えば<Button>であれば、通常はHTML上で<button>としてレンダリングされるが、asを使用することでレンダリングされるタグを変更できる。
また、asを使ったときは使えるpropsも変化し、asで指定したタグのpropsが使えるようになり、指定しなかった時のpropsは使おうとするとType Errorになる。(なお、コンポーネント固有のpropsがある場合はそれはasを使っても使わなくても使える)
利点としては見た目は揃えつつタグを変更してHTMLをセマンティックに扱うことができるというところがある。
code:tsx
// 普通に使用すると<button>としてレンダリング
<Button>ボタン</Button>
// asを使うことでレンダリングされるタグを変更できる
code:html
<!-- レンダリング結果 -->
<!-- どっちにもbuttonというclassがついているので見た目は同じ -->
<button class="button">ボタン</button>
as propsの実現のためには色々方法があり、LogRocketの記事などでも実装方法が紹介されているが、これを素直に実装するとtscへの負担が大きく、普段のプログラミングにかなりの支障をきたす。 また、as propsで実装されるコンポーネントは大抵の場合基底コンポーネントのため、refの取り回しをしたい場合が多いが、forwardRef()を使って実装しようとすると結構大変。
Chakra UIでは独自のforwardRef()を定義し、型をオーバーライドすることで型解析の負荷を下げつつ、forwardRef()の外側に対しての型安全性を担保している。 今回はChakra UIのコードを参考にしつつ、独自のforwardRef()を実装してみた。
code:tsx
import type {
ComponentPropsWithoutRef,
ComponentPropsWithRef,
ElementType,
ForwardRefExoticComponent,
ForwardRefRenderFunction,
ReactNode,
} from "react";
import { forwardRef } from "react";
/** 第2引数にオーバーライドされるobjectのmerge */
type RightJoin<T, U> = Omit<T, keyof U> & U;
/** 追加propsと基底となるタグのpropsとasをmergeしたpropsを返す */
type MergePropsWithAs<
Component extends ElementType,
AdditionalProps extends object
= RightJoin<ComponentPropsWithRef<Component>, AdditionalProps> & {
as?: Component;
};
/** 本来のforwardRef()の返り値からコンポーネントのレンダリング関数の型のみを除いた型 */
type ForwardRefExoticComponentWithoutFunction = Pick<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ForwardRefExoticComponent<any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keyof ForwardRefExoticComponent<any>
;
/**
* as propsを使用する為の型
*
* @template BaseComponent - asを使わない場合の基底となるタグ
* @template Props - as以外の追加のprops
*/
export interface PolymorphicComponent<
BaseComponent extends ElementType,
Props extends object = object
extends ForwardRefExoticComponentWithoutFunction {
/**
* レンダリング関数
*
* @template AsComponent - asで指定されたタグ(指定されない場合はBaseComponent)
*/
<AsComponent extends ElementType = BaseComponent>(
props: MergePropsWithAs<AsComponent, Props>
): ReactNode;
}
/**
* as propsを使用したコンポーネントを作成する
*
* @template Component - asを使わない場合の基底となるタグ
* @template Props - as以外の追加のprops
* @param displayName - コンポーネント名
* @param render - レンダリング関数
*/
export function polymorphicComponent<
Component extends ElementType,
Props extends object = object
(
displayName: string,
render: ForwardRefRenderFunction<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
RightJoin<ComponentPropsWithoutRef<Component>, Props> & { as?: ElementType }
): PolymorphicComponent<Component, Props> {
const component = forwardRef(render) as unknown as PolymorphicComponent<
Component,
Props
;
component.displayName = displayName;
return component;
}