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

この資料は、[Coral Capital](https://coralcap.co/)の主催する[スタートアップ React LT](https://coralcapital.connpass.com/event/218552/)に[株式会社 kikitori](https://kikitori.jp/)の CTO として登壇した際の内容を再編集したものです。

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

https://github.com/chelproc/20210715-coral-react-lt

## 概要

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 オブジェクトが格納されます。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample01)

```tsx
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 は、クラスコンポーネントのインスタンスを取得するための仕組みでした。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample02)

```tsx
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 を通して呼び出すことができるようになります。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample03)

```tsx
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 を利用できません。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample04)

```tsx
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 を公開できます。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample05)

```tsx
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 を受け渡ししていました。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample06)

```tsx
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 として使用しています。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample07)

```tsx
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` を設定するのもお忘れなく。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample08)

```tsx
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-use](https://github.com/streamich/react-use)の[useMountedState](https://github.com/streamich/react-use/blob/master/src/useMountedState.ts)は、コンポーネントが現在マウントされているかどうかを返す関数を取得します。この例では、Ref の参照が常に同一であることを利用し、コンポーネントがアンマウントされた後も変更・取得できる状態として Ref を作成しています。

```tsx
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-redux](https://github.com/reduxjs/react-redux)の[useSelector](https://github.com/reduxjs/react-redux/blob/master/src/hooks/useSelector.js)では、`selector` によって取得されたストアの一部分が変化したかどうかを判断するために Ref を利用しています。これにより、同一であると判断された場合に再レンダリングを抑制することができるようになります。

```tsx
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](https://kikitori.jp/) のプロダクトは、業務システム的な側面が強いです。業務システムでは、表形式の UI が採用される場合が多々あります。表形式の UI を作成する場合、フォーカスを適切に管理していく必要があります。

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

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

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

## React Hook Form

![React Hook Form](https://raw.githubusercontent.com/react-hook-form/react-hook-form/master/docs/logo.png)

[React Hook Form](https://react-hook-form.com/)は、Ref を上手に活用することで、ハイパフォーマンスなフォームの実装を可能にしたライブラリです。

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

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

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample09)

```tsx
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 を保存する

```tsx
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 }` を返す
- 使う側はスプレッド構文で展開するだけ

```tsx
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 で記述できるようになっています。

[サンプル](https://chelproc.github.io/20210715-coral-react-lt/samples/sample10)

```tsx
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](https://kikitori.jp/)は、日本の農業流通の 8 割を占める市場流通の DX を目指す SaaS、[nimaru](https://nimaru.jp/) の開発に携わってくださる方を募集しています。詳細は[弊社ウェブサイト](https://kikitori.jp/)をご覧ください。