blog.takurinton.dev

forwardRef を習得した

2021-11-01

こんにちは

どうも、僕です。
最近色々あって、Material UI のコードを読んでいるのですが、forwardRef がさまざまな箇所で出てきて理解できないと辛いので勉強しました。

ref とは

ref とは、簡単に言うと、dom であれば element、それ以外の要素であればその class のインスタンスに対してアタッチするためのものです。class component だと、要素 + インスタンスにアクセスできるわけです。
逆に、function component だと ref をそのまま渡すことができません。そんな時に、この記事のタイトルでもある forwardRef を私用します。forwardRef を使用すると、function component にも ref を渡すことができるようになります。

ref を使うタイミング

ref を使うタイミングとしては、以下のようなものがあります。

  • フォーカス、テキストの選択およびメディアの再生の管理
  • アニメーションの発火
  • サードパーティの DOM ライブラリとの統合

なんか元々の JS で要素にアタッチしていた時代を感じますが、まさにそれで、宣言的に書ける部分には ref は使うなと公式ドキュメントにも書かれています。state と ref の使い分けが難しいですが、基本的に state を使うべきだと思います。コンポーネントのどの階層で状態を保持するべきなのかを考えれば、自然と答えは見えてくるはずで、自分は最近 React を書き始めて、難しいことを考えずにもらった値を描画するだけに注力すれば勝手に見えてくると思います。多分、ロジックを持つべき部分は上の階層にあると思いますし、そこで状態を持つことを検討する方が先でしょう。

function component での ref

上で、function component では、ref を渡せないという話をして、forwardRef を使うといいという話をしました。
その例を示します。
例えば、以下のような、ローディングと同時にフォーカスされたボタンをレンダリングするとします。
これがレンダリングされると、最初からボタンがフォーカスされた状態になります。もちろん text box や select box でも同じことができますし、tabIndex を使えば div などのその他の要素でも同じことができます。
Material UI が modal を閉じる処理を esc の keydown で行うあたりで同じようなことをしています。

import { useRef, useEffect } from 'react'

function App() {
  const ref = useRef<HTMLDivElement>();

  useEffect(() => {
    ref.current?.focus();
  }, []);

  return (
    <div>
      <button id="takurinton" ref={ref}>focus now</button>
    </div>
  );
};

export default App;

はい ref が渡せました、おしまい。とはなりません。
通常、このような単純なアプリケーションで済むわけがなく、もっとずっと複雑になります。当然、button コンポーネントはここにいるとは限りません。そこで、button と App を分離します。
従順なたくりんとんは以下のように定義しました。

import { useRef, useEffect } from 'react'

function App() {
  const ref = useRef<HTMLDivElement>();

  useEffect(() => {
    ref.current?.focus();
  }, []);

  return (
    <div>
      <Button ref={ref} />
    </div>
  );
};

const Button = (ref: HTMLDivElement) => {
  return <button id="takurinton" ref={ref}>focus now</button>;
};

export default App;

いけません、これではエラーです。
どうしてでしょうか?答えは簡単で、関数はインスタンスを持たないからです。もともと、ref とはコンポーネントのマウントされたインスタンスを返すものです。つまり、インスタンスを持たないとアクセスする先がなく、エラーになってしまいます。( 参考

そんなときは、Button component を以下のように修正します。
このように forwardRef で囲ってあげることによって ref を渡すことができるようになります。また、props も渡すことができ、第一引数で受け取ることができます。

const Button = forwardRef<HTMLDivElement>((props, ref) => {
  return <button id="takurinton" ref={ref}>focus now</button>;
});

forwardRef と children

forwardRef で注意しないといけないことがあります。
それは、component の中で component を展開したい時、つまり children を受け取りたい時には、props の中で children の型を明示的に宣言しないといけないということです。

例えば以下のような component があるとします。
おそらく、これは普通に機能すると思います。

export type HogeProps = {
  a: string;
  b: () => void;
};

const HogeComponent = ({ a, b children }: HogeProps) => {
  // 処理
  return <>{children}</>
};

ここに forwardRef を足すとしたらこうなると思います。

export type HogeProps = {
  a: string;
  b: () => void;
};

const HogeComponent = forwardRef<HTMLDivElement, HogeProps>(
  ({ a, b children }, ref) => {
    // 処理
    return <>{children}</>
  },
);

実際に動かしてみると、これでは動かないことがわかります。
理由は、children の型明示的に定義されていないからです。
つまり、以下のようになります。

export type HogeProps = {
  a: string;
  b: () => void;
  children: ReactNode; // これを追加
};

const HogeComponent = forwardRef<HTMLDivElement, HogeProps>(
  ({ a, b children }, ref) => {
    // 処理
    return <>{children}</>
  },
);

type error を吐かないようにするためにも、ここらへんは注意して実装をしたいです。

まとめ

ライブラリ開発や、設計の段階で component を使い回すことを考えたときに、ref を扱うことは必須になってきますし、そこの扱いを気をつけないと失敗するのでしっかりやっていきたいです。