37
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

React の Ref とフォーカス管理におけるベストプラクティス

Last updated at Posted at 2021-07-15

この資料は、Coral Capitalの主催するスタートアップ React LT株式会社 kikitoriの CTO として登壇した際の内容を再編集したものです。

サンプルコード等は、以下のリポジトリで公開しています。

概要

React における Ref は、宣言的 UI を掲げる React の思想とは対極に位置する枠組みです。それ故に、Ref についての議論が交わされることは少ないと感じています。しかしながら、Hooks の登場により、Ref を用いる命令的なコードは、カスタムフックの中に隠蔽することが可能になりました。代表的な Ref の用途であるフォーカスの管理を例に、Ref を効果的に用いるライブラリの実装を参考にしながら、命令的な「汚い」コードを抽象化する手法について論じます。

そもそも Ref とは何か?

React の公式ドキュメントには、以下のように記載されています。

Ref は render メソッドで作成された DOM ノードもしくは React の要素にアクセスする方法を提供します。

-- https://ja.reactjs.org/docs/refs-and-the-dom.html

Ref の基本的な使い方

useRefは、Ref オブジェクトが返却します。このオブジェクトを ref 属性に渡すと、マウントされたタイミングで current フィールドに DOM オブジェクトが格納されます。

サンプル

export default function Sample1() {
  const ref = useRef<HTMLInputElement>(null);

  return (
    <>
      <input ref={ref} />
      <button
        onClick={() => {
          ref.current?.focus();
        }}
      >
        focus
      </button>
    </>
  );
}

Ref の歴史

React の Ref は 3 種類存在しています。

String Refs

React の初期リリースから存在している Ref です。すでに deprecated なので扱いません。

Callback Ref (v0.13.0 / 2015 年)

コンポーネントがマウントされる際にインスタンスオブジェクトがコールバックの引数に渡されます。String Refs より柔軟に Ref が扱えるようになりました。

Ref オブジェクト (v16.3.0 / 2018 年)

Callback Ref よりも開発者にとって使いやすいインターフェースを目指して開発されました。

Class Component 時代の Ref

Class Component 時代の Ref は、クラスコンポーネントのインスタンスを取得するための仕組みでした。

サンプル

class Child extends Component {
  render() {
    return <div>Hello World</div>;
  }
}

export default class Sample02 extends Component {
  divRef = createRef<HTMLDivElement>();
  divElement: HTMLDivElement | null = null;
  childRef = createRef<Child>();

  render() {
    return (
      <>
        {/* Ref オブジェクトを使う場合 */}
        <div ref={this.divRef} />
        {/* Callback Ref を使う場合 */}
        <div
          ref={(element) => {
            this.divElement = element;
          }}
        />
        <Child ref={this.childRef} />
      </>
    );
  }
}

対象が DOM 要素であれば DOM オブジェクトが、カスタムコンポーネントであればそのインスタンスが取得できます。1 つ目の例は Ref オブジェクトを使用した例で、2 つ目の例は Callback Ref です。

例えば、子コンポーネントにパブリックメソッドが存在すれば、親コンポーネントから ref を通して呼び出すことができるようになります。

サンプル

class Child extends Component<{}, { count: number }> {
  state = { count: 1 };

  increment() {
    this.setState((state) => ({ count: state.count + 1 }));
  }

  render() {
    return <div>{this.state.count}</div>;
  }
}

export default class Sample03 extends Component {
  ref = createRef<Child>();

  render() {
    return (
      <>
        <Child ref={this.ref} />
        <button
          onClick={() => {
            this.ref.current?.increment();
          }}
        >
          increment
        </button>
      </>
    );
  }
}

Hooks 以降の Ref

Hooks 以降の React では、コンポーネントは関数によって表現されるため、インスタンスという概念が存在しません。このため、関数コンポーネントでは Ref を利用できません。

サンプル

function Child() {
  return <div>Child</div>;
}

export default function Sample04() {
  const ref = useRef<any>(null);

  // @ts-expect-error Property 'ref' does not exist on type 'IntrinsicAttributes'.
  return <Child ref={ref} />;
}

ただし、forwardRef を使うと function component でも ref を公開できます。

サンプル

const Child = forwardRef<HTMLInputElement>(function Child(_, ref) {
  return <input ref={ref} />;
});

export default function Sample05() {
  const ref = useRef<HTMLInputElement>(null);

  return (
    <>
      <Child ref={ref} />
      <button
        onClick={() => {
          ref.current?.focus();
        }}
      >
        focus
      </button>
    </>
  );
}

ちなみに、ref という属性名にこだわらなければ別に forwardRef を使う必要はありません。forwardRef が導入される以前は ref 以外の属性を利用して Ref を受け渡ししていました。

サンプル

function Child(props: { innerRef: Ref<HTMLInputElement> }) {
  return <input ref={props.innerRef} />;
}

export default function Sample06() {
  const ref = useRef<HTMLInputElement>(null);

  return (
    <>
      <Child innerRef={ref} />
      <button
        onClick={() => {
          ref.current?.click();
        }}
      />
    </>
  );
}

しかしながら、例えば HOC を作る場合等、シグネチャが勝手に変わるのは困るという場合もあるでしょう。また、ライブラリ作成者が Class Component → Function Component に移行する場合、これまで ref を使っていたコードとの互換性を維持するため、forwardRef を使用すると良いでしょう。

また、useImperativeHandle を用いることで自由な値を ref に設定できます。以下の例では、単純な関数を Ref として使用しています。

サンプル

type ChildRef = () => void;

const Child = forwardRef<ChildRef>(function Child(_, ref) {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => () => {
    inputRef.current?.focus();
  });

  return <input ref={inputRef} />;
});

export default function Sample07() {
  const ref = useRef<ChildRef>(null);

  return (
    <>
      <Child ref={ref} />
      <button
        onClick={() => {
          ref.current?.();
        }}
      >
        focus
      </button>
    </>
  );
}

useEffect を用いた useImperativeHandle の再実装

Ref を受け取ったコンポーネントは親コンポーネントにその Ref を通して自らを命令的に操作できるオブジェクトを伝えればよいので、useImperativeHandle の機能は大体 useEffect を使用してマウント時やアンマウント時に処理を行うことで代替できます。アンマウント時に null を設定するのもお忘れなく。

サンプル

useEffect(() => {
  const refValue = () => {
    inputRef.current?.focus();
  };

  // Callback Refの場合
  if (typeof ref === "function") {
    ref(refValue);
    return () => {
      ref(null);
    };
  }

  // Ref オブジェクトの場合
  if (ref) {
    ref.current = refValue;
    return () => {
      ref.current = null;
    };
  }
});

Hooks 後の Ref のユースケース

Ref の用途:

  • DOM にアクセスする
  • Class Component のインスタンス変数の代わり
    • ミュータブル
    • 変更しても再レンダリングされない

ここまでは Ref を DOM にアクセスするための仕組みとして扱ってきましたが、Hooks 以降の Ref は、むしろ Class Component のインスタンス変数の代わりとして扱われる場合のほうが多いです。

Ref の DOM 以外のユースケース

useMountedState

react-useuseMountedStateは、コンポーネントが現在マウントされているかどうかを返す関数を取得します。この例では、Ref の参照が常に同一であることを利用し、コンポーネントがアンマウントされた後も変更・取得できる状態として Ref を作成しています。

export default function useMountedState(): () => boolean {
  const mountedRef = useRef<boolean>(false);
  const get = useCallback(() => mountedRef.current, []);

  useEffect(() => {
    mountedRef.current = true;

    return () => {
      mountedRef.current = false;
    };
  }, []);

  return get;
}

useSelector

react-reduxuseSelectorでは、selector によって取得されたストアの一部分が変化したかどうかを判断するために Ref を利用しています。これにより、同一であると判断された場合に再レンダリングを抑制することができるようになります。

function useSelectorWithStoreAndSubscription(selector, equalityFn, store) {
  const latestSelector = useRef();
  const latestStoreState = useRef();
  const latestSelectedState = useRef();

  let selectedState;
  if (
    selector !== latestSelector.current ||
    storeState !== latestStoreState.current
  ) {
    const newSelectedState = selector(storeState);
    if (!equalityFn(newSelectedState, latestSelectedState.current))
      selectedState = newSelectedState;
    else selectedState = latestSelectedState.current;
  } else {
    selectedState = latestSelectedState.current;
  }
  return selectedState;
}

業務システム開発にて

弊社 kikitori のプロダクトは、業務システム的な側面が強いです。業務システムでは、表形式の UI が採用される場合が多々あります。表形式の UI を作成する場合、フォーカスを適切に管理していく必要があります。

例えば、「Enter キーを押されたら次のセルに移動するようにして!」と言われたらどうしますか?

jQuery の時代だったら簡単に解決できた話ですが、時代は宣言的 UI です。React でフォーカスを操作するには Ref を使用せざるを得ないですが、どのように実装するのが良いでしょうか?

問題は、useRef の戻り値を DOM に渡すことで取得できるのは 1 要素までであるということです。しかしながら、大量の要素のフォーカス管理を行うためには、すべての要素を取得しておかなければなりません。当然のことながら、useRefを何度も呼ぶという方法は Hook の制約に抵触するため、使用することができません。

React Hook Form

React Hook Form

React Hook Formは、Ref を上手に活用することで、ハイパフォーマンスなフォームの実装を可能にしたライブラリです。

React のフォーム要素は、value 属性を指定すると制御(controlled)コンポーネントになります。制御コンポーネントでは、value 属性に指定した値が常にinputに入力される値と同期されることが React によって保証されます。通常 React でフォーム要素を利用する場合は、フォームの値を State で管理します。この方法は通常うまく動作するのですが、フォームが入力される度に再レンダリングが発生するため、パフォーマンスが悪化します。

React Hook Form は、Ref を通してフォームの DOM 要素に直接アクセスするため、フォーム要素は非制御のままになります。

サンプル

type FormValue = {
  name: string;
};

export default function Sample09() {
  const { register, handleSubmit } = useForm<FormValue>();

  return (
    <form
      onSubmit={handleSubmit((e) => {
        alert(e.name);
      })}
    >
      <input {...register("name")} />
      <input type="submit" />
    </form>
  );
}

React Hook Form の動作原理

React Hook Form のサンプルコードで最も印象的なのは明らかに register 関数です。この関数は、次のように動作します。

  • register{ onChange(), ref } の形のオブジェクトを返す
    • 使う側はスプレッド構文で展開するだけで使える
  • ref は Callback Ref
  • Callback Ref 内で連想配列の Ref オブジェクトに ref を保存する
function useForm() {
  const ref = useRef<Record<string, HTMLInputElement>>({});
  const register = (name: string) => ({
    onChange() {},
    ref(element: HTMLInputElement | null) {
      ref.current[name] = element;
    },
  });
  return { register };
}

フォーカス管理の Custom Hook

React Hook Form の仕組みは、Hooks を用いて複数の DOM 要素の管理する際の実装の指針となるものでしょう。「Enter キーが押されたら次のセルにフォーカスを移す」を実現するためには、各 input 要素に対し、キーイベントを取得するための onKeyDown イベントハンドラと、要素のフォーカスを強制的に設定するための ref を渡す必要があります。このため、作成するカスタムフックは、以下のような内容になるでしょう。

  • useRef の中で Map<number, HTMLInputElement> を管理する
  • Hook の戻り値は register 関数で、フォーカスの順番を引数に指定すると { onKeyDown(), ref } を返す
  • 使う側はスプレッド構文で展開するだけ
function useEnterKeyFocusControl() {
  const ref = useRef(new Map<number, HTMLInputElement>());
  return (index: number) => ({
    onKeyDown({ key }: { key: string }) {
      if (key !== "Enter") return;
      const sortedIndices = [...ref.current.keys()].sort();
      const nextIndex = sortedIndices[sortedIndices.indexOf(index) + 1];
      if (typeof nextIndex === "number") ref.current.get(nextIndex)?.focus();
    },
    ref(element: HTMLInputElement | null) {
      if (element) ref.current.set(index, element);
      else ref.current.delete(index);
    },
  });
}

ポイントは、ref 関数の実装です。useImperativeHandle の例で見たように、Callback Ref は、要素がマウントされた際にその要素が、アンマウントされた際に null が引数にセットされて呼ばれます。要素数が動的に変化するような UI にも対応できるよう、上記の例では、nullが渡ってきた(=要素がアンマウントされた)際に Map から要素を削除しています。

この Hook を使用すると、「Enter キーが押されたら次のセルにフォーカスが移る」は、次のように実装できます。フォーカスを管理するという処理が、コンポーネント側から見ると宣言的な API で記述できるようになっています。

サンプル

export default function Sample10() {
  const register = useEnterKeyFocusControl();
  return (
    <>
      {[...Array(10).keys()].map((i) => (
        <input key={i} {...register(i)} />
      ))}
    </>
  );
}

まとめ

  • Hooks 時代の Ref の用途は 2 つ
    • DOM へのアクセス
    • ミュータブルな値を、レンダリングから独立して管理する
  • useRef で得られるのは Ref オブジェクトだが、Callback Ref を組み合わせて使うとより柔軟に制御できる
  • 「Callback Ref を生成する関数」を返す Custom Hook を作ると複数要素の Ref を高い抽象度で扱える

We're Hiring!

弊社 kikitoriは、日本の農業流通の 8 割を占める市場流通の DX を目指す SaaS、nimaru の開発に携わってくださる方を募集しています。詳細は弊社ウェブサイトをご覧ください。

37
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
37
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?