再利用性の高いコンポーネント「ポリモーフィックコンポーネント」を作るための型定義を考える
呼び出す際に、HTML Element type を選択できるコンポーネント
コンポーネントにはよく「高い再利用性」を求められる。それはそうで、再利用性が高くないと、わざわざ一つの責務に閉じたパーツに切り出した意味がないからである。
世間に公開されているコンポーネントライブラリを見ると、非常に小さいコンポーネント、例えば「Button」や「Link」、「Typography(Text)」といった単位で作っている。
ところで、HTML には「Semantics Element」というものがあり、「h1」「h2」、「header」「footer」、「ul」「ol」「li」といったように、各 Element にはそれぞれ「役割」が与えられている。
コンポーネント設計を考える場合、この「Semantics」を考慮するために、コンポーネントを使用する側で Element type を指定できるようにすることがある。
例えば、React だと以下のような、
const Component = () => (
<>
{/* Expect <h1>見出し</h1> */}
<Text as="h1">見出し</Text>
{/* Expect <p>テキスト</p> */}
<Text as="p">テキスト</Text>
{/* Expect <h2>小見出し</h2> */}
<Text as="h2">小見出し1</Text>
{/* Expect <p>テキスト</p> */}
<Text as="p">テキスト</Text>
{/* Expect <h2>小見出し</h2> */}
<Text as="h2">小見出し2</Text>
{/* Expect <p>テキスト</p> */}
<Text as="p">テキスト</Text>
</>
);
as Props のように、外から Element type を指定して使うことは、「Semantics を考慮した設計」ができていると言えるはずだ。
このように、「呼び出す際に、HTML Element type を選択できるコンポーネント」を一般的に「Polymorphic Components」と呼ぶ。
Polymorphic Components とは
「インスタンス化する際に、複数の形に姿を変えるコンポーネント」のことである。
さきほども述べたような、「呼び出す際に、HTML Element type を選択できるコンポーネント」も Polymorphic Components であると言える。
世間では、Polymorphic Components のことをこの意味で使っていることが多い。
Styled Components では、asprops によって Element type を分けるやり方をPolymorphicComponentと呼んでいるように見える。
作り方
import * as React from "react";
type Props = {
as?: React.ElementType;
children: React.ReactNode;
};
export const Component = (props: Props) => {
const Element = props.as || "div";
return <Element>{props.children}</Element>;
};
const Component = () => (
<div className="App">
{/* <p>element p</p> */}
<Component as="p">element p</Component>
{/* <div>element div</div> */}
<Component as="div">element div</Component>
</div>
);
終わり。
as に指定したものによって props の型を変える
as には div や p、はたまた react-router-dom の Link コンポーネントなんかを指定できる。
このとき、せっかくなので、as で指定したものに応じて、props の型が変わるととても便利だろう。
例えば、p だとhrefは指定できない警告が出て、react-router-dom の Link だと、to props を指定するように警告を出すみたいなこと。
これは TypeScript の型の表現で可能。
import * as React from "react";
/**
* type A = {
* id: string
* name: string
* }
*
* type B = {
* id: number
* age: number
* }
*
* Merge<A,B>
* => { id: number name: string age: number }
*/
type Merge<T, U> = Omit<T, keyof U> & U;
type PolymorphicPropsName = "as";
export type PolymorphicComponentProps<
Props,
Element extends React.ElementType
> = Merge<
Element extends keyof JSX.IntrinsicElements
? React.PropsWithRef<JSX.IntrinsicElements[Element]>
: React.ComponentPropsWithRef<Element>,
Props & { [key in PolymorphicPropsName]?: Element }
>;
2つの type を指定する generics を作った。
Props には、そのコンポーネントがほしい Props を、Element には、そのコンポーネントに渡ってくる可能性のある要素型を指定する。
これをコンポーネントに適用すると、以下のようになる。
import * as React from "react";
import { PolymorphicComponentProps } from "./types";
type FeatureProps = {
children: React.ReactNode;
foo: string;
};
const defaultElement = "div";
export const Component = <E extends React.ElementType = typeof defaultElement>(
props: PolymorphicComponentProps<FeatureProps, E>
) => {
const { as, children, ...restProps } = props;
const Element = as || defaultElement;
return <Element {...restProps}>{children}</Element>;
};
こうすると、as に何も入れないと div になり、div が欲している props と、コンポーネントが欲している props が一緒になったものが Component の Props に渡ってくる。
as に要素を入れると、その要素が欲している props と、コンポーネントが欲している props が一緒になったものが Component の Props に渡ってくる。
これで、Polymorphic Components を作るのに便利な型が完成した。
もっといろいろなパターンに対応させたい
残念ながら、上記の型では、React.memo を使ったり、React.lazy を使ったり、React.forwardRef を使ったコンポーネントには対応できない。
react-polymorphic-typesというパッケージは、Polymorphic Components を React で作るための型定義を提供していて、いろいろなパターンでも使える型定義も提供している。
中はシンプルな型定義になっているので、参考にして自分で作ってみるのもいいと思う。
最後に
「コンポーネント設計難しいね」と常々思っていて、特に「汎用的な props 設計ができた、非常に小さいコンポーネントさえできてたら、それらを組み合わせて良い設計ができるのに」と思うことが多々ある。
Polymorphic Components は非常に強力な設計パターンであることは確実なので、数ある設計手法の一つとしてぜひ自分のものにしたい。