React.forwardRef で Generic 型 が扱えない問題の対処方法

目次

はじめに

React.forwardRef では ジェネリック型を持った Props を扱うことができない問題があり、ハックする必要があります。複数の対処方法があり、それぞれのメリット、デメリットを解説します。
(React 18, TypeScript 4.9 を使用しています)

背景

下記のような ジェネリック型を持った Props を渡す必要がある Component1 を Component2 から呼び出したい場合を考えます。
const Component2 = <T,>(
  props: Component1Props<T>
) => (
  <Component1<T> {...props} />
);
Ref も渡す必要があったため、 React.forwardRef を用いて実装しようとしたのですが、下記のような形でしか実装することができず、 Component1Props にジェネリクスを渡すことができません。
const Component2 = React.forwardRef<HTMLDivElement, Component1Props>(
(props, ref) => (
  <Component1 ref={ref} {...props} />
))
React.forwardRef は下記のように型が定義されており、拡張ができないためです。
function forwardRef<T, P = {}>(render: ForwardRefRenderFunction<T, P>): 
  ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
この関数はレンダー関数 ForwardRefRenderFunction を受け取り、 ForwardRefExoticComponent 型を持つコンポーネントを返します。 displayNamedefaultProps のプロパティを追加するために使用しています。

対処方法

1. キャストする方法

まずはキャストする方法です。
const Component2 = React.forwardRef<HTMLDivElement, Component1Props<any>>(
(props, ref) => (
  <Component1 ref={ref} {...props} />
)) as <T>(p: Component1Props<T> & { ref?: React.Ref<HTMLDivElement> }) => JSX.Element
<Component2<number> ref={ref} />
構文としてはわかりやすく、使う側も理解しやすい利点があります。
一方、変更に弱かったり、Type Error が発生しなくなってしまう可能性があるなど、あまり TypeScript の恩恵を受けられない方法でもあります。

2. forwardRef を使用しない

forwardRef が登場する前は、prop として ref を渡していました。当然現バージョンでも動作するため、 ref の prop を別途作成する方法もあります。
React の公式ドキュメント でもおすすめされている方法です。
const Component2 = <T,>(
  props: Component1Props<T> & { customRef: Ref<HTMLDivElement> }
): JSX.Element => <Component1<T> ref={customRef} {...props} />;
<Component2<number> customRef={ref} />
プログラム上では一番シンプルで変更にも強いという大きな利点があります。
一方、ドキュメントや実装を読まないと ref を渡す方法がわからないため、コンポーネントを利用する側で混乱が起こる可能性があります。

3. forwardRef の型を上書きして型推論を効かせる

次に 高階関数推論 を活用する方法です。StackOverflow で見かけたので引用します。 (CC-BY-SA-4.0)
TypeScript 3.4 から 高階関数推論 が導入されました。今回渡したいジェネリクスを prop を経由して伝搬させることができます。
// @types/react.d.ts
import React from "react"

declare module "react" {
  function forwardRef<T, P = {}>(
    render: (props: P, ref: ForwardedRef<T>) => JSX.Element | null
  ): (props: P & RefAttributes<T>) => JSX.Element | null
}
interface Component1Props<T> {
  value: T;
}

const Component2Wrapped = <T, P = {}>(
  props: Component1Props<P>,
  ref: React.ForwardedRef<T>
): JSX.Element => <Component1 ref={ref} {...props} />;

const Component2 = React.forwardRef(Component2Wrapped);
<Component2 ref={ref} value={2} />
元々の forwardRef の型と変わらなく見えます。しかし、TypeScript のアーキテクトである Anders Hejlsberg さん曰く
We only make higher order function type inferences when the source and target types are both pure function types, i.e. types with a single call signature and no other members.
とのことで、高階関数推論が動作していませんでした。この型の置き換えによって、純粋関数になったので高階関数推論が動作するようになります。
この方法のメリットは 1 箇所のみの修正で済むためプログラムが煩雑にならず変更にも強くなります。使用する側も ref で渡せるので内部実装を意識する必要がありません。
一方デメリットとして、declare module による型定義は経験上(読み込み順の問題、さらなる上書きなどの)トラブルがつきもので、後々維持していく上で問題になる可能性があります。

まとめ

React.forwardRef では ジェネリック型を持った Props を扱うことができない問題がありますが、3つの対処方法を紹介しました。
ref を用いないのが当然ベストですが、どうしても使う必要がある場合、ドキュメントをちゃんとした上で 2. forwardRef を使用しない方法を用いるのが、保守性を考えて一番良い方法だと思います。
一方、ライブラリとして提供する場合は、1. キャストする方法を用いると使用する側からの混乱を防ぐことができそうです。

シェア

Twitter
Facebook
はてブ
LinkedIn
LINE
Pocket