🐹

TypeScript で React.forwardRef を簡潔に書く方法

2021/04/18に公開

はじめに

Dwango でニコニコ生放送のフロント開発を担当している misuken です。

TypeScript で React.forwardRef を簡潔に書く方法を紹介します。

React では Hooks が登場して以来、関数コンポーネントが主流となり、 forwardRef を使用する機会も増えているのではないでしょうか。

そんな身近な forwardRef ですが、以下のような問題に悩まされている方も少なくないはずです。

  • 型の渡し方がよくわからない
  • 型の渡し方が面倒くさい
  • たまに型が通らないときがある

そんなことを感じながら使っていたら、この記事を参考にしてみてください。

時間の無い方ヘ

react-frec を使うと、 forwardRef 周りをシンプルに書けますよというお話です。

forwardRef の書き方に満足していますか?

forwardRef の型パラメータには、 ref で扱うインスタンスの型コンポーネントの Props の型 を渡せるようになっており、 様々な型を自分で用意して渡すようになっています。

HTML の要素をそのままラップして利用する場合、 <button> に対応する型は JSX.IntrinsicElements["button"] で得ることができるので以下のように書けます。

// forwardRef<ref で扱うインスタンスの型, コンポーネントの Props の型>
type ButtonProps = JSX.IntrinsicElements["button"];
const Component = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
  return <button {...props} ref={ref} />
});

しかし、この書き方の場合、 "button"HTMLButtonElement<button> を連動させる必要があり、冗長で面倒です。

型に慣れていない人にとっても、複数箇所の整合性を合わせるのでは負担が大きくなります。

forwardRef は戻り値の型が複雑

forwardRef で作成したコンポーネントの型を見てみると、思いの外複雑な型になっています。

React.ForwardRefExoticComponent<
  Pick<
    React.DetailedHTMLProps<
      React.ButtonHTMLAttributes<HTMLButtonElement>,
      HTMLButtonElement
    >,
    "key" | keyof React.ButtonHTMLAttributes<HTMLButtonElement>
  > & React.RefAttributes<HTMLButtonElement>
>

型に慣れている人は読めば理解できるものの、そこまで展開してほしくないですし、型に慣れていない人からしたらもはや何が起きているのか全くわからないことでしょう。

簡単に書けるようにする

この問題は、コンポーネントを代入する際に使用する型を用意することで解決できます。

以下のコードでは、ちゃんと propsref にも適切な型が推論される上、 HtmlComponent1HtmlComponent2ElementFrec<"button"> というエイリアス型で表示されるため、型も把握しやすくなっています。

import React, { forwardRef, ForwardRefExoticComponent, PropsWithRef } from "react";
export type ElementFrec<T extends keyof JSX.IntrinsicElements> = ForwardRefExoticComponent<PropsWithRef<JSX.IntrinsicElements[T]>>;

const HtmlComponent1: ElementFrec<"button"> = forwardRef((props, ref) => {
  return <button {...props} ref={ref} />;
});

// タグ名をこうすると1箇所の修正で全てが連動します
const TagName = "button";
const HtmlComponent2: ElementFrec<typeof TagName> = forwardRef((props, ref) => {
  return <TagName {...props} ref={ref} />;
});

playground で実際のコードを確認

タグ名以外でも使えるようにする

ElementFrec は HTML のタグ名しか対応していませんが、 forwardRef の中でクラスコンポーネントや、 ref の対象が HTMLElement ではない Props を持つコンポーネントもあります。

そのような場合にも対応した型も用意します。

export type Frec<T extends Component | ForwardRefExoticComponent<any> | ClassAttributes<any> | keyof JSX.IntrinsicElements> =
  // クラスコンポーネント用
  T extends Component<infer P>
  ? ForwardRefExoticComponent<P & RefAttributes<T>>
  // ref を持つ Props 用
  : T extends ClassAttributes<any>
  ? ForwardRefExoticComponent<PropsWithRef<T>>
  // タグ名用
  : T extends keyof JSX.IntrinsicElements
  ? ElementFrec<T>
  // ForwardRefExoticComponent 用
  : T;

これで様々なコンポーネントの型でダイレクトに利用できるようになりました。

ref を転送して欲しいコンポーネントや Props 型が手元にあるとき、わざわざそのコンポーネントの Props 型を参照する必要も、冗長な記述もなくスマートに書けるようになります。

const TagName = "button";

// タグ名を利用
const HtmlComponent: Frec<typeof TagName> = forwardRef((props, ref) => {
  return <TagName {...props} ref={ref} />;
});

// ForwardRefExoticComponent を再利用
const HtmlComponent2: Frec<typeof HtmlComponent> = forwardRef((props, ref) => {
  return <HtmlComponent {...props} ref={ref} />;
});

// FooComponent と FooProps がクラスコンポーネントとして定義されている想定
declare class FooComponent extends Component<{ foo?: string }>{};
// クラスコンポーネントを利用
const ClassComponent: Frec<FooComponent> = forwardRef((props, ref) => {
  return <FooComponent {...props} ref={ref} />;
});

playground で実際のコードを確認

styled-component と連携する場合

自分は普段 styled-component を使用していないので、もっと良い方法があるかもしれませんが、ざっと試した見た感じ、以下のように書くことで型が通ります。

この場合 Button<button> の受け付ける Props 型と同じになります。

const tagName = "button";
const Styled = styled[tagName]``;
const Button: Frec<typeof tagName> = forwardRef((props, ref) => {
  return <Styled {...props} ref={ref} />;
});

まとめ

今回は forwardRef を使う際に面倒と感じる部分を簡潔に書く方法を紹介しました。
シンプルで明確な書き方が決まっているとスッキリしますね。

型推論が便利であると改めて感じました。

Frec を手軽に使いたい方もいると思うので、 react-frec として公開しています。

もし気になったら是非使ってみてはいかがでしょうか。

型の話

ここからはオマケで React の型の話をします。
React の型に詳しくない方も、これを読むと少し理解できる範囲が広がるかもしれません。

記事の上のほうで、普通に forwardRef を使用した場合、戻り値の型が複雑になるという話をしました。
なぜそういうことが起きるのかを一つずつ追って解説します。

React.DetailedHTMLProps は後で説明するので、 <button> に渡せる型とだけ理解しておけば大丈夫です。

React.ForwardRefExoticComponent<
  Pick<
    React.DetailedHTMLProps<
      React.ButtonHTMLAttributes<HTMLButtonElement>,
      HTMLButtonElement
    >,
    "key" | keyof React.ButtonHTMLAttributes<HTMLButtonElement>
  > & React.RefAttributes<HTMLButtonElement>
>

PropsWithoutRef 型の役割

この複雑さの根本は forwardRef 関数の戻り値型にある PropsWithoutRef<P> から来ています。

// T は HTMLElement の型で P は Props の型
function forwardRef<T, P = {}>(
    render: ForwardRefRenderFunction<T, P>
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

PropsWithoutRef<P> はその名の通り、 Props である P から ref を無くす挙動をとります。

これは P{ ref?: Ref<?> } が含まれていた場合。P & RefAttributes<T> とすると RefAttributes<T> つまり事実上の { ref?: Ref<T> } と交差型で合成する時に { ref?: Ref<?> } & { ref?: Ref<T> } となり ? の部分の内容によっては Ref<?> & Ref<T> で不整合が発生する可能性があるので、 Pref のキーが含まれていたらそれを抜いた型にする。ということをやっています。

つまり、置換の形で上書き(Overwrite)している感じです。

PropsWithoutRef 型の中身

次に PropsWithoutRef の定義を見てみましょう。

type PropsWithoutRef<P> =
    // Just Pick would be sufficient for this, but I'm trying to avoid unnecessary mapping over union types
    // https://github.com/Microsoft/TypeScript/issues/28339
    'ref' extends keyof P
        ? Pick<P, Exclude<keyof P, 'ref'>>
        : P;

'ref'P の持つ全てのキーのいずれかに代入可能な条件を満たしていたら、 Pick<P, Exclude<keyof P, 'ref'>> それ以外なら P をそのまま返すように定義されています。

次は Pick<P, Exclude<keyof P, 'ref'>> の意味です。

Pick<P, Exclude<keyof P, 'ref'>>

これは P というオブジェクトの型から Exclude<keyof P, 'ref'> のキーだけピックアップしたオブジェクトの型を得るということです。

次に Exclude<keyof P, 'ref'> の意味です。

Exclude<keyof P, 'ref'>

これは P というオブジェクトの型が持つ全てのキーから 'ref' だけ除外するということです。

除外系の型を使うと型が展開されやすく、一気に理解しにくい型になりがちなので、型を作るときは引き算よりも足し算で作れるようにしたり、引き算するにも足した部分を引けるようにするなど工夫すると、理解しやすい型にできることがあります。

除外の仕組み

PReact.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> のとき、キーは React.ButtonHTMLAttributes<HTMLButtonElement> の持つキー(つまり <button> が持つ属性名全て)と "key""ref" で構成されています。

つまり React.ButtonHTMLAttributes<HTMLButtonElement> が HTML 責務の属性で、 React.DetailedHTMLProps の中で React 責務のプロパティ "key""ref" が追加されているということになります。

ここから "ref" を除外すると keyof React.ButtonHTMLAttributes<HTMLButtonElement>"key" になります。

すると最初に紹介した以下の部分となり、 Props"ref" 以外のキー全てを持った型となります。

  Pick<
    React.DetailedHTMLProps<
      React.ButtonHTMLAttributes<HTMLButtonElement>,
      HTMLButtonElement
    >,
    "key" | keyof React.ButtonHTMLAttributes<HTMLButtonElement>
  >

意味としては実質以下と同じです。

Omit<JSX.IntrinsicElements["button"], "ref">

これに & React.RefAttributes<HTMLButtonElement>ref 部分だけ足すことで、 Ref の不整合が起きないようになっています。

Overwrite 不要なパターン

不整合が起きる可能性があるパターンに関しては、たしかに必要な手続きではあるものの、確実に不整合が発生しないことがわかっている場合もあり、そのパターンを効率化しているのが Frec 型です。

例えば、以下のような場合。

type ButtonProps = JSX.IntrinsicElements["button"];
const Component = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
  return <button {...props} ref={ref} />
});

ButtonPropsrefRef<HTMLButtonElement> であることがわかっており、これを Overwrite したとしても、結果の型としては変わりません。(同じ型を抜いて追加するのは無意味)

結果が変わらないのに型を冗長に渡したり、戻り値の型がわかりにくくなっては損するだけなので、戻り値のほうの型から forwardRef の引数まで推論させることにより、効率化したというわけです。

DetailedHTMLProps 型

最後に DetailedHTMLProps 型の話をしておきましょう。

この型は、主に JSX.IntrinsicElements で使用されており、各要素ごとに対応する React.XxxHTMLAttributes<HTMLXxxElement> の組み合わせが定義されています。

<a> には IntrinsicElements["a"] が、 <button> には IntrinsicElements["button"] が Props 型として対応しています。

declare global {
    namespace JSX {
        // ...
        interface IntrinsicElements {
            // HTML
            a: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>;
            abbr: React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>;
            // ...
            // SVG
            svg: React.SVGProps<SVGSVGElement>;
            // ...
        }

そして、この DetailedHTMLProps 型の定義を追っていくと結局は "ref""key" という React 責務の型を追加しているだけであることがわかります。

    // E は HTML属性 の型で T は HTMLElement の型
    type DetailedHTMLProps<E extends HTMLAttributes<T>, T> = ClassAttributes<T> & E;

    // T は HTMLElement の型
    interface ClassAttributes<T> extends Attributes {
        ref?: LegacyRef<T>;
    }

    // Attributes の中身
    interface Attributes {
        key?: Key | null;
    }
    type Key = string | number;

    // LegacyRef の中身
    // T は HTMLElement の型
    type LegacyRef<T> = string | Ref<T>;
    type Ref<T> = RefCallback<T> | RefObject<T> | null;
    type RefCallback<T> = { bivarianceHack(instance: T | null): void }["bivarianceHack"];
    interface RefObject<T> {
        readonly current: T | null;
    }

フラット化するとこんな感じで意外とシンプルです。

type DetailedHTMLProps<E extends HTMLAttributes<T>, T> = {
  key?: string | number | null;
  ref?: string
    | { bivarianceHack(instance: T | null): void }["bivarianceHack"]
    | { readonly current: T | null }
    | null
} & E

refstringLegacyRef という名前の定義に含まれているだけあって、廃止予定の文字列形式 ref のために残してある型なので、いずれ消えることでしょう。

bivarianceHack に関しては f_subal さんの bivarianceHack とは何か、なぜ必要なのか が参考になります。

Discussion