🧩

React.forwardRef で TypeScript のジェネリック型を扱う方法

2023/02/21に公開

ハコベルシステム開発部のおおいし (@bicstone) です。普段はフロントエンドエンジニアとして物流DX SaaSプロダクトの開発を行なっています。

この記事では、React.forwardRef でTypeScriptのジェネリック型を持ったPropsを扱うことができないという問題の対処方法について紹介します。

(React 18, TypeScript 4.9を使用しています)

背景

ハコベルでは、コンポーネントの共通利用を見越したライブラリ化を進めています。UIコンポーネントの実装にあたり、refを転送する必要があったのですが、TypeScriptのジェネリック型を持ったPropsを扱うことができないという問題に直面しました。

例として、次のようなジェネリック型を持った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>>;

対処方法

この問題に対して3つの対処方法があります。それぞれのメリット、デメリットを解説します。

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} value={2} />

プログラムとしてわかりやすく、使う側も理解しやすい利点があります。

一方、変更に弱かったり、Type Errorが発生しなくなってしまう可能性があるなど、あまりTypeScriptの恩恵を受けられない方法でもあります。

2. forwardRef を使用しない

次にforwardRefを使用せず、refを転送するpropを別途作成する方法です。forwardRefが実装される前は、propとしてrefを渡していました。現バージョンでも動作し、React の公式ドキュメント でも紹介されている方法です。

const Component2 = <T,>(
  props: Component1Props<T> & { customRef?: Ref<HTMLDivElement> },
): JSX.Element => <Component1<T> ref={customRef} {...props} />;
<Component2<number> customRef={ref} value={2} />

プログラム上では一番シンプルで変更にも強いという大きな利点があります。

一方、React開発者で広く知られている命名である ref propでなく customRef propで渡す必要があるため、コンポーネントを利用する側で混乱を引き起こす可能性があります。

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

次に高階関数推論を活用する方法です。

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} />

StackOverflow Answered by ford04 (CC BY SA 4.0)

元々のforwardRefの型と一見変わらなく見えます。しかし、TypeScriptのアーキテクトであるAnders Hejlsbergさんによると、高次関数型推論が行えるのは単一の呼び出しシグネチャと他のメンバーを持たない純粋関数の場合のみであると説明しています[1]

元々の型は純粋関数でないため、高階関数推論が動作していませんでした。この型の置き換えによって、純粋関数になったため高階関数推論が動作するようになります。

この方法のメリットは、1箇所のみの修正で済むためプログラムが煩雑にならず変更にも強くなります。使用する側もref propで渡せるので内部実装を意識する必要がありません。

一方デメリットとして、declare module による型定義は、読み込み順の問題やさらなる上書きなど、後々維持していく上でトラブルの原因になる可能性があります。

まとめ

React.forwardRef ではジェネリック型を持った props を扱うことができない問題があり、3 つの対処方法を紹介しました。

今回、私達はUIコンポーネントライブラリとして提供するため、使用する側の混乱を軽減するために「1. キャストする」方法を用いることにしました。

refを用いないで実装をするのがベストですが、どうしても使う必要がある場合、ドキュメントを整えた上で「2. forwardRefを使用しない」方法を用いるのが、保守性を考えてベターな方法だと考えています。

脚注
  1. https://github.com/microsoft/TypeScript/issues/30650#issuecomment-486680485 ↩︎

Hacobell Developers Blog

Discussion