ymmr
Recent Articles
    公開日: 2023/12/02

    React.forwardRef の課題とその代替案

    HTML の dialog 要素がすべてのモダンブラウザーでサポートされました。早速モーダルコンポーネントの実装に取り入れてみたのですが、 ref の取り扱いに迷うところがあったので記事にします。

    Ref を経由して特定の DOM ノードにアクセスする

    通常ステートレスな React コンポーネントは、props によって何がレンダリングされるか決まります (宣言的 UI)。しかし、コンポーネント内の DOM ノードにアクセスして focus() を呼び出したいなど、親コンポーネントから子の DOM ノードを操作したいケースがあります。

    React では親コンポーネントで定義した ref を子コンポーネントに forwarding することで、特定の DOM ノードにアクセスします。

    import { forwardRef, useRef, useState } from 'react';
    
    // 子コンポーネント
    const Textbox = forwardRef<
    //              ^^^^ 受け取った ref を forwarding する
      HTMLInputElement,
      { text: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }
    >(function Child({ text, onChange }, ref) {
      return <input type="text" onChange={onChange} value={text} ref={ref} />;
      //                                                         ^^^^ 親コンポーネントからアクセスしたい DOM ノードに ref を代入する
    });
    
    // 親子コンポーネント
    const InputForm = () => {
      const initialText = '';
      const [text, setText] = useState(initialText);
      // ref を宣言する
      const inputRef = useRef<HTMLInputElement>(null);
    
      const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        setText(e.currentTarget.value);
      };
      const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
    
        alert(text);
        setText(initialText);
        // current プロパティで DOM ノードを参照できる
        inputRef.current?.focus();
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <Textbox text={text} onChange={handleChange} ref={inputRef} />
          {/*                                          ^^^^ 子コンポーネントに ref を渡す */}
          <button type="submit">Submit</button>
        </form>
      );
    };
    
    export default InputForm;
    

    やりたかったこと

    dialog 要素は showModal メソッドで自身をモダールとして表示したり、close メソッドで閉じたりできます。

    Members というコンポーネントを作るにあたり、モーダルの状態をその中で管理する必要がありました。これらのメソッドをイベントハンドラから呼び出すには、最下層に位置する Modal コンポーネントの dialog 要素まで ref を forwarding しなければなりません。

    はじめは forwardRef で ref をバケツリレーするつもりでしたが、以下のような課題が浮上しました。

    • 親コンポーネントに ref を渡すことを強制したい
    • ref を中継するコンポーネントでイベントハンドラを宣言したい
    • Container / Presentational Pattern を使うため 2 つの ref を渡したい

    これらの課題を解決する方法を調べたところ、ref を props として渡す方法を見つけました。順を追って見ていきましょう。

    value of using React.forwardRef vs custom ref propI see that React.forwardRef seems to be the sanctioned way of passing a ref to a child functional component, from the react docs: const FancyButton = React.forwardRef((props, ref) =&gt; ( &lt;but...stackoverflow.com

    親コンポーネントに ref を渡すことを強制したい

    forwardRef の型定義は以下のようになっています。

    declare namespace React {
        // ...
        function forwardRef<T, P = {}>(render: ForwardRefRenderFunction<T, P>): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
    
        interface RefAttributes<T> extends Attributes {
          ref?: Ref<T> | undefined;
        }
    }
    

    返り値の一部となっている RefAttributes の ref プロパティは省略可能かつ undefined を許容しています。Modal コンポーネントは ref の受け渡しが不可欠であることを考えると、このインターフェースは都合が悪いと感じました。

    一方で Modal コンポーネントの ref を props として受け取ると次のようになります。

    Modal.tsx
    import type { ComponentPropsWithRef } from 'react';
    
    type Props = ComponentPropsWithRef<'dialog'> & {
      dialogRef: React.RefObject<HTMLDialogElement>;
      children: React.ReactNode;
    };
    
    const Modal = ({ dialogRef, children }: Props) => (
      <dialog ref={dialogRef}>{children}</dialog>
    );
    
    export default Modal;
    

    ref を必要とすることが明示的になり、ref を渡し損ねると TypeScript がエラーを出してくれます。

    ref を中継するコンポーネントでイベントハンドラを宣言したい

    Members コンポーネントにロジックが集中しているため、少しでもコード量を減らしたいと考えました。そこで ref を中継するコンポーンネント (FilterModal や SortModal) にイベントハンドラを切り分けるというアイデア (コンポーネント間の依存関係が高まるため、よい実装なのかはわかりません🤷‍♂️) を思いついたのですが、ここでも forwardRef の型が問題となります。

    Ref<T>RefCallback<T>RefObject<T> という 2 つ側面を持つため、DOM ノードの参照に使う RefObject として TypeScript に推論させることは困難です。

    declare namespace React {
        // ...
        interface RefAttributes<T> extends Attributes {
          ref?: Ref<T> | undefined;
        }
    
        type Ref<T> = RefCallback<T> | RefObject<T> | null;
    }
    

    そこで FilterModal コンポーネントも ref を props で受け取るように変更してみます。props の型として RefObject<HTMLDialogElement> まで絞り込めるため、current プロパティにアクセスできます。

    FilterModal.tsx
    import Modal from '@/components/Modal';
    
    import type { ComponentProps } from 'react';
    
    // 上記の Modal コンポーネントと同じ型の Props を用いる
    type Props = ComponentProps<typeof Modal>;
    
    const FilterModal = ({ dialogRef, children }: Props) => {
      const handleClose = () => {
        dialogRef.current?.close();
      };
    
      return (
        <Modal dialogRef={dialogRef}>
          {children}
          <button type="button" onClick={handleClose} />
        </Modal>
      );
    };
    
    export default FilterModal;
    

    Container / Presentational Pattern を使うため 2 つの ref を渡したい

    複数の ref を取り扱う方法を調べたところ、React 公式が提示している情報は見つけられませんでした。どうやら useImperativeHandler というフックを使って、forwarding した ref をカスタマイズする方法があるようです。

    Exposing multiple DOM elements to a parent Component using the useImperativeHandle hook – The WordPress Voyagethewpvoyage.com

    props であれば必要な分だけプロパティを増やすだけで済みます。

    type Props = {
      filterModalRef: RefObject<HTMLDialogElement>;
      sortModalRef: RefObject<HTMLDialogElement>;
    };
    

    おわりに

    現在 React の公式ドキュメントには ref を props で渡す方法は記載されていません。この方法を採用する場合は自己責任となりますが、forwardRef ではカバーしにくいエッジケースで役に立つと感じました。