Material-UI Modal に自前コンポーネントを入れると Warning: Function components cannot be given refs. が出る

suzuki

こんにちは、 Gaji-Labo フロントエンドエンジニアの鈴木です。

Web アプリケーションを作っているとどうしても Modal Dialog を使わないといけないことありますよね。
自前のコンポーネントを作って Material-UI Modal に反映した際に、思わぬ Warning が出て解消に手間取ってしまったので再現コードを作って整理しました。

現象の確認

CodeSandbox に再現コードを用意しました。

デモの URL を開くと Chrome DevTools の console に下記のようなメッセージが出ます。

Warning: Failed prop type: Invalid prop `children` supplied to `ForwardRef(Modal)`.
Expected an element that can hold a ref.
Did you accidentally use a plain function component for an element instead?
For more information see https://material-ui.com/r/caveat-with-refs-guide
    at Modal (https://uifiv.csb.app/node_modules/@material-ui/core/esm/Modal/Modal.js:61:41)
    at RefErrorExample (https://uifiv.csb.app/src/pages/RefErrorExample.tsx:40:31)
    at Route (https://uifiv.csb.app/node_modules/react-router/esm/react-router.js:426:29)
    at Switch (https://uifiv.csb.app/node_modules/react-router/esm/react-router.js:566:29)
    at div
    at Router (https://uifiv.csb.app/node_modules/react-router/esm/react-router.js:164:30)
    at BrowserRouter (https://uifiv.csb.app/node_modules/react-router-dom/esm/react-router-dom.js:156:35)
    at App

「ダイアログを開く」ボタンを押すと <Modal /> 自体は表示されるのですが、次のようなメッセージが出ます。

Warning: Function components cannot be given refs.
Attempts to access this ref will fail.
Did you mean to use React.forwardRef()?

Check the render method of `Unstable_TrapFocus`.
    at Foo (https://uifiv.csb.app/src/pages/RefErrorExample.tsx:22:23)
    at Unstable_TrapFocus (https://uifiv.csb.app/node_modules/@material-ui/core/esm/Unstable_TrapFocus/Unstable_TrapFocus.js:17:24)
    at div
    at Portal (https://uifiv.csb.app/node_modules/@material-ui/core/esm/Portal/Portal.js:22:24)
    at Modal (https://uifiv.csb.app/node_modules/@material-ui/core/esm/Modal/Modal.js:61:41)
    at div
    at RefErrorExample (https://uifiv.csb.app/src/pages/RefErrorExample.tsx:40:31)
    at Route (https://uifiv.csb.app/node_modules/react-router/esm/react-router.js:426:29)
    at Switch (https://uifiv.csb.app/node_modules/react-router/esm/react-router.js:566:29)
    at div
    at Router (https://uifiv.csb.app/node_modules/react-router/esm/react-router.js:164:30)
    at BrowserRouter (https://uifiv.csb.app/node_modules/react-router-dom/esm/react-router-dom.js:156:35)
    at App
Chrome の DevTools に Warning が出ている様子

問題になる構造

実際のコードは CodeSandbox にありますが、要約すると下記のような構造の場合にこの現象が起きます。

// 自前のコンポーネントを独立して作成し
function Foo({ children }: { children: string }): React.ReactElement {
  return (
    <div style={{ margin: "200px", padding: "100px", background: "white" }}>
      {children}
    </div>
  );
}

// ...

// コンテナー側(呼び出し元)の Material-UI Modal 直下で使用する
export function RefWarningExample() {
  //...
      <Modal open={isOpen} onClose={handleClose}>
        <Foo>てきすと</Foo>
      </Modal>
  //...
}

ちなみに下記のように HTMLElement 相当の ReactElement や Material-UI のコンポーネントであれば問題は発生しません。
※すべての Material-UI のコンポーネントが問題ないかは確認していないので例外はあるかもしれません。

Modal のなかで作業をして最後に別のコンポーネントとして切り出そうとしたときに遭遇することが多そうです。

<Modal open={isOpen} onClose={handleClose}>
    <div>てきすと</div>
</Modal>

// ...

import { Modal, Text } from "@material-ui/core";
// ...
<Modal open={isOpen} onClose={handleClose}>
    <Text>てきすと</Text>
</Modal>

解決策

解決策はダイアログを開いたときに提案される forwardRef を使って、参照を自前コンポーネントの root のHTML要素に渡すことで解決できます。

Did you mean to use React.forwardRef()?

Material-UI の <Modal /> はツリー構造の外側にノードを移動してダイアログを表示するため、そこで各種参照が途切れてしまうのが原因のようです。
ルート要素が div などのHTML要素だったり対応済みの Material-UI コンポーネントであれば暗黙的に参照が渡されているため問題は起きません。

ということで自前コンポーネントも対応していきます。

ドキュメントは最初の Warning に表示されている下記を参照しました。

基本的な対応

ドキュメントには下記のように React.forwardRef で取得した ref を div に渡しています。

-const SomeContent = props => <div {...props}>Hello, World!</div>;
+const SomeContent = React.forwardRef((props, ref) => <div {...props} ref={ref}>Hello, World!</div>);
<Tooltip title="Hello, again."><SomeContent /></Tooltip>;

すでにある自前コンポーネントに対応する場合

今回は独立したコンポーネントを作成しているため、できればコンポーネント定義内では React.forwardRef をしたくありません。
そのため HOC で対応することにしました。

function Foo({ children, forwardRef }: FooProps): React.ReactElement {
  return (
    <div
      ref={forwardRef} // forwardRef(名前は任意) として受け取って div の ref に渡している
      tabIndex={0}
      style={{ margin: "200px", padding: "100px", background: "white" }}
    >
      {children}
    </div>
  );
}

// ...

const RefFoo = React.forwardRef<HTMLDivElement, FooProps>(
  ({ children }, ref) => {
    // RefFoo という HOC を作成して Foo の forwardRef に ref を渡している
    return <Foo children={children} forwardRef={ref} />;
  }
);

// ...

      <Modal open={isOpen} onClose={handleClose}>
        <RefFoo>てきすと</RefFoo> /* RefFoo を使用する */
      </Modal>

HOC で対応する場合、<Foo ref={ref} /> のようにしてしまうと Foo が参照を受け取ってしまうため問題が解決しません。
必ず ref 以外の prop を定義してバケツリレーする必要があります。(ここでハマりました)

型定義

TypeScript を利用する場合 forwardRef として受け取る ref の型を FooProps に定義する必要があります。
指定する方は React.Ref<FooBar> のような形になります。 FooBar の部分は渡す先の HTMLElement を指定します。
今回は div に渡しているので下記のような定義にしました。

interface FooProps {
  children: string;
  forwardRef?: React.Ref<HTMLDivElement>;
}

p か div が渡ってくるような場合は該当する Element を列挙します。

  forwardRef?: React.Ref<HTMLDivElement | HTMLParagraphElement>;

とあるサンプルで FooBar の部分に unknown を指定すると良いというのを見かけてここでもハマりましたが、 VSCode のヒントに従うことで解決しました。

解決

これでダイアログを開いても元の warning が出なくなりました。

Chrome の DevTools に Warning がでなくなった様子

おまけ

ここまでの対応だと下記のような Material-UI のメッセージが新たに出るようになります。

Material-UI: The modal content node does not accept focus.
For the benefit of assistive technologies, the tabIndex of the node is being set to "-1". 

Modal を開くとモーダル直下に focus がうつる仕様になっているのですが、一部のリンクや form 系の要素をのぞいた大半の要素は tab-index 指定がないと focus を受け取れません。

function Foo({ children, forwardRef }: FooProps): React.ReactElement {
  return (
    <div
      ref={forwardRef}
      tabIndex={0} // Modal から focus を受け取るために tabIndex を指定
// ...
}

ちなみにメッセージでは tab-index に -1 を指定するよう表示されていますが、これをすると暗黙的に focus があたり、いわゆるフォーカスリングが表示されません。
デザインや要件次第ですが、focus 移動が明示できるように 0 を指定するのをおすすめします。指定するのは string ではなく number な点も要注意です。

Gaji-Laboでは、Jamstackが得意なフロントエンドエンジニアを募集しています

弊社ではJamstackの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!


suzuki

投稿者 suzuki

フロントエンドグループリード。
HTML/CSS のマークアップから始まり、現在は React や TypeScript を使ったコンポーネント実装をすることが多いです。淡々と実装するだけではなくコミュニケーションを取りながら、チームとしてプロジェクトを前に進めることを意識しています。最近は会社の成長へのコミットに関心があり、組織・チーム全体で強まるためにはどうするのだろう?ということを考えています。