30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

forwardRef と useImperativeHandle

コンポーネント内の DOM 要素にRefオブジェクトを渡すための機能であるforwardRefと、コンポーネントにメソッドを生やす Hooks であるuseImperativeHandleについて、説明する。
どちらもRefオブジェクトやref属性を使った機能なので、それらを理解していることが前提になる。
理解が不十分な場合はまず Ref の基本を学ぶことをおすすめする。

numb86-tech.hatenablog.com

この記事のコードは React のv16.10.2で動作確認している。

forwardRef

forwardRefを学ぶための題材として、テキストボックスへのフォーカスを扱う。

以下のコードでは、focusボタンを押すとテキストボックスにフォーカスする。

import React, {useRef} from 'react';

const App = () => {
  const ref = useRef();

  const onClick = () => {
    ref.current.focus();
  };

  return (
    <div>
      <input ref={ref} type="text" />
      <br />
      <button type="button" onClick={onClick}>
        focus
      </button>
    </div>
  );
};

export default App;

Refオブジェクトをinput要素のref属性に渡すことで、input要素の操作(この例ではフォーカス)が可能になる。

では以下のように、input要素がApp直下の要素ではなく、子コンポーネントの要素だった場合は、どうすればいいのか。

import React from 'react';

const MyInput = () => {
  return <input type="text" />;
};

const App = () => {
  return (
    <div>
      <MyInput />
    </div>
  );
};

export default App;

<MyInput ref={ref} />のようにMyInputpropsとしてrefを受け取り、さらにそれをinputref属性に渡す、ということは出来ない。
refは特殊な属性で、propsには含まれないからだ。そもそも、関数コンポーネントにrefを渡すことは出来ない。

そこで、forwardRefを使う。これを使うことで、関数コンポーネントにrefを渡すことができ、さらにそのなかで自由にrefを使うことが出来る。

forwardRefは関数を返す関数で、引数として関数コンポーネントを渡すと、そのコンポーネントに機能を追加した新しいコンポーネントを返す。
その追加機能によって、関数コンポーネントにref属性を設定できるようになり、設定されたrefは関数コンポーネントの第二引数として受け取る。

以下の例では、forwardRefMyInputを渡して、MyInputをベースとしたWrappedMyInputという新しい関数コンポーネントを作成している。
そして<WrappedMyInput ref={ref} />とすることで、そのコンポーネントは第二引数としてrefを受け取るようになる。

import React, {useRef} from 'react';

const MyInput = (props, ref) => {
  console.log(ref); // {current: undefined}
  return <input type="text" />;
};

const WrappedMyInput = React.forwardRef(MyInput);

const App = () => {
  const ref = useRef();

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

export default App;

この機能を利用することで、App側から子コンポーネント内の DOM 要素を操作することが出来る。

import React, {useRef} from 'react';

const MyInput = (props, ref) => {
  return <input ref={ref} type="text" />;
};

const WrappedMyInput = React.forwardRef(MyInput);

const App = () => {
  const ref = useRef();

  const onClick = () => {
    ref.current.focus();
  };

  return (
    <div>
      <WrappedMyInput ref={ref} />
      <br />
      <button type="button" onClick={onClick}>
        focus
      </button>
    </div>
  );
};

export default App;

さて、ここまで書いてきたが、実はforwardRefを使わなくても、同等のことは出来る。
refは特殊な属性だと書いたが、裏を返せば、refとは異なる別の名前でRefオブジェクトを渡してしまえば、それで済む。
それがこのサンプル。

import React, {useRef} from 'react';

const MyInput = props => {
  return <input ref={props.customRef} type="text" />;
};

const App = () => {
  const ref = useRef();

  const onClick = () => {
    ref.current.focus();
  };

  return (
    <div>
      <MyInput customRef={ref} />
      <br />
      <button type="button" onClick={onClick}>
        focus
      </button>
    </div>
  );
};

export default App;

customRefという属性を用意し、それを通してpropsのバケツリレーを行うことで、問題なくinput要素にRefオブジェクトを渡せている。

そのため、forwardRefの必要性が、私にはよく分からない。
各々が独自の属性を用意するのではなく、Refオブジェクトを渡す属性はrefに固定してしまう、ということが可能になるのが、利点なのかもしれない。

useImperativeHandle

useImperativeHandleは Hooks のひとつで、関数コンポーネントにメソッドを追加し、それを親コンポーネントから使えるようにするための機能。
言葉で説明するとイメージしづらいが、サンプルを見れば、大して難しい話でもないということが分かると思う。

説明に入る前に断っておくと、他のRefに関する機能と同様、useImperativeHandleも基本的には使用を避けるべき。
公式ドキュメントにも、以下のように書かれている。

いつもの話ですが、ref を使った手続き的なコードはほとんどの場合に避けるべきです

あくまでもこのような機能もある、という話であり、実際に使うケースはほとんどない。

useImperativeHandleを使うにはまず、Refオブジェクトを関数コンポーネントに渡す必要があるので、forwardRefを使うことにする。
そして関数コンポーネントのなかで、以下のようにuseImperativeHandleを使う。使う場所は、他の Hooks と同様に、関数コンポーネントのトップレベル。

  useImperativeHandle(親コンポーネントから受け取った`Ref`オブジェクト, () => ({
    // ここに、追加したいメソッドを書いていく
  }));

具体例として、MyInputlogというメソッドを持たせた。

import React, {useRef, useImperativeHandle} from 'react';

const MyInput = (props, ref) => {
  useImperativeHandle(ref, () => ({
    log: () => {
      console.log('called log');
    },
  }));

  return <input type="text" />;
};

const WrappedMyInput = React.forwardRef(MyInput);

const App = () => {
  const ref = useRef();

  const onClick = () => {
    ref.current.log(); // called log
  };

  return (
    <div>
      <WrappedMyInput ref={ref} />
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
};

export default App;

こうするとApp側でref.current.logを実行できるようになる。

これも、forwardRefを使わずに書くことが可能。

const MyInput = props => {
  useImperativeHandle(props.customRef, () => ({
    log: () => {
      console.log('called log');
    },
  }));

  return <input type="text" />;
};

useImperativeHandle の依存配列

useImperativeHandleは第三引数に依存配列を渡すことが出来る。
依存配列については以下の記事に詳しく書いたが、これを正しく使うことで、同じ内容のメソッドを何度も作り直してしまうことを防げる。

numb86-tech.hatenablog.com

まずは依存配列を使わないパターン。
ボタンを押す度にstateが更新されるため再レンダーが発生し、useEffectが実行される。
その度にMyInputも再レンダーされるため、都度useImperativeHandleが実行される。
そうするとsomeMethodも新しく作り直されるため、Xは常にfalseとなる。

import React, {useRef, useImperativeHandle, useEffect, useState} from 'react';

const MyInput = (props, ref) => {
  useImperativeHandle(ref, () => ({
    someMethod: () => {},
  }));

  return <input type="text" />;
};

const WrappedMyInput = React.forwardRef(MyInput);

const list = [];

const App = () => {
  const ref = useRef();

  const [state, setState] = useState(0);

  useEffect(() => {
    list.push(ref.current.someMethod);

    if (list.length >= 2) {
      console.log(list[list.length - 2] === list[list.length - 1]); // X
    }
  });

  const onClick = () => {
    setState(s => s + 1);
  };

  return (
    <div>
      <WrappedMyInput ref={ref} attr={state} />
      <br />
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
};

export default App;

MyInputを以下のように書き換えると、useImperativeHandleは初回レンダー時にしか実行されないため、someMethodは同じ参照を指し続ける。
そのため、Xは常にtrueになる。

const MyInput = (props, ref) => {
  useImperativeHandle(
    ref,
    () => ({
      someMethod: () => {},
    }),
    []
  );

  return <input type="text" />;
};

参考資料