型ジェネリクスを用いてコンポーネントを作る方法をまとめておく。もちろんFunctional Componentを前提とします。
型ジェネリクスを用いたReactコンポーネント
まずはいちばん簡単な例を示します。
type Props<T> = {
data: T
}
const Component = <T,>({ data }: Props<T>) => {
return (
<p>{`これは${typeof(data)}型のコンポーネントです。`}</p>
)
}
ポイントとしては、関数の型ジェネリクスで<T,>
とすることです。TypeScriptであれば関数の型ジェネリクスは通常<T>() => {}
としますが、Reactコードの場合<T>
はタグとみなされてしまうため、それとの区別のために<T,>
とする必要があります。
もしくは、以下のような形でもOKです。extends
を使えばコンパイラにタグではなく型ジェネリクスであるということを伝えられます。Tに型制約を入れるなら基本この形を使いましょう。
const Component = <T extends {}>({ data }: Props<T>) => {
// ...
}
このコンポーネントを使うときはこんな感じです。型引数を明示することもできます。
const WrapperComponent: React.FC = () => (
<Component data="文字列" />
)
// これでもOK(型を明示する場合)
const WrapperComponent: React.FC = () => (
<Component<string> data="文字列" />
)
// 明示した型とPropsがマッチしない場合はエラー
const WrapperComponent: React.FC = () => (
<Component<number> data="文字列" /> // これはエラー、dataはnumber型
)
型ジェネリクスを用いたコンポーネントをメモ化する
ダメな例
上記のコンポーネントをメモ化します。普段通り、React.memo
でコンポーネントをラップしてみます。これだと一見エラーは発生しません。
// エラーは発生しないので正しいコードのように見える
const Component = React.memo(<T extends {}>({ data }: Props<T>) => {
// ...
})
Component.displayName = "Component"
const WrappedComponent: React.FC = () => (
<Component data="文字列" />
)
しかし、この例には落とし穴があります。以下のようなコンポーネントを考えてみます。
type Props<T> = {
data: T
arg: T
}
const Component = <T extends {}>({ data, arg }: Props<T>) => {
return (
<p>{`${typeof(data)} ${typeof(arg)}`}</p>
)
}
const MemorizedComponent = React.memo(Component);
MemorizedComponent.displayName = "MemorizedComponent"
const WrappedComponent: React.FC = () => (
// 型が異なるのにエラーが発生しない!
<MemorizedComponent data="文字列" arg={42} />
)
data
とarg
の型が異なっているのにも関わらずエラーが発生しません。これはReact.memo
でラップされたことによりMemorizedComponent
自体がProps
に関して型ジェネリックになっていないためです。具体的には、MemorizedComponent
の型はReact.MemoExoticComponent
型になっており、Component
のPropsの型に関して無関心です。
メモ化する前と同じように呼び出し側で型引数を用いるとエラーになります。
const Component = React.memo(<T extends {}>({ data }: Props<T>) => {
// ...
})
Component.displayName = "Component"
// `<string>`部分でエラーが発生
// Expected 0 type arguments, but got 1.
const WrappedComponent: React.FC = () => (
<Component<string> data="文字列" />
)
これもコンポーネントがReact.memo
でラップされたことに起因するものです。React.memo
が型ジェネリクスを引数として期待していないため、そもそもTypeScriptの構文としておかしくなっている、と言えます。
正しい例
メモ化したコンポーネントに型アサーションをすることでエラーを解消できます。型アサーションなのでもちろん実装する際は細心の注意を払う必要があります。
type Props<T> = {
data: T
arg: T
}
const Component = <T extends {}>({ data, arg }: Props<T>) => {
return (
<p>{`${typeof(data)} ${typeof(arg)}`}</p>
)
}
// Componentとして型アサーションをする
const MemorizedComponent = React.memo(Component) as typeof Component
// エラーが出る例
const WrappedComponent: React.FC = () => (
// 今度はちゃんとエラーが出る!
<MemorizedComponent data="文字列" arg={42} />
)
// 正しい例
const WrappedComponent: React.FC = () => (
<MemorizedComponent data="文字列" arg="もじれつ" />
)
具体的にはComponent
として型アサーションをするので、MemorizedComponent
の型は<T extends {}>({ data, arg }: Props<T>) => React.JSX.Element
となります。これによりメモ化以前と同様に型ジェネリクスを効かせることができます。
displayNameをつける
上記の例では、MemorizedComponent
にdisplayName
プロパティをつけることができません。
// Property 'displayName' does not exist
MemorizedComponent.displayName = "MemorizedComponent"
Component
として型アサーションをしているので、基本上記のやり方でもESLintさんには怒られません。しかし、displayName
を付けたい場合は以下のようにすればOKです。
const MemorizedComponent = React.memo(Component) as typeof Component & { displayName: string }
MemorizedComponent.displayName = "MemorizedComponent"