【React】createPortalによるポータルの作成・利用方法

JavaScript

ポータル(Portal)を利用することでモーダルやトーストのような『画面上に飛び出すUIパーツ』を実装できます。
今回はcreatePortalを利用したポータルの実装方法について紹介します。

検証環境はnext 12.1.4react 18.0.0です。

createPortalについて

createPortalはポータルを作成するメソッドです。
createPortalは引数を2つ持ちます。第1引数(child)にはReactの子要素としてレンダー可能な要素、第2引数(container)にはDOM要素を指定します。

下準備

ポータルの検証で利用するコンポーネント(以下でいうExample)を呼び出すPageを作成しておきます。

src/pages/index.tsx

import type { NextPage } from "next";
import { Example } from "../components/Example";

const Home: NextPage = () => {
  return <Example />;
};

export default Home;

createPortalの第1引数(child)の指定パターン

コンポーネントを利用する場合

src/components/Example.tsx

import { FC } from "react";
import { ExamplePortal } from "src/components/shared/ExamplePortal";

export const Example: FC = () => {
  return (
    <>
      <div>Example</div>
      <ExamplePortal />
    </>
  );
};

src/components/shared/ExamplePortal.tsx

import { FC, useEffect, useState } from "react";
import { createPortal } from "react-dom";

export const ExamplePortal: FC = () => {
  const [showPortal, setShowPortal] = useState(false);

  useEffect(() => {
    setShowPortal(true);
  }, []);

  if (!showPortal) {
    return null;
  }

  return createPortal(
    <h1>Hello Portals</h1>,
    document.getElementById("__next")! // __next: Next.jsのroot divに適用されているid
  );
};

ローカルState(showPortal)を導入してSSRでWindowを参照しないようにしています。
詳細は【Next.js】SSR/SGでブラウザ機能(Cookieなど)を活用する際の注意点で紹介しています。

なお、document.getElementById("__next")!!はTypeScriptのNon-null assertion operatorです。

コンポジション(props.children)を利用する場合

第1引数にはコンポジションの指定もできます。

src/components/Example.tsx

import { FC } from "react";
import { ExamplePortal } from "src/components/shared/ExamplePortal";

export const Example: FC = () => {
  return (
    <>
      <div>Example</div>
      <ExamplePortal>
        <h1>Hello Portals</h1>
      </ExamplePortal>
    </>
  );
};

src/components/shared/ExamplePortal.tsx

import { FC, ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";

type Props = {
  children: ReactNode;
};

export const ExamplePortal: FC<Props> = ({ children }) => {
  const [showPortal, setshowPortal] = useState(false);

  useEffect(() => {
    setshowPortal(true);
  }, []);

  if (!showPortal) {
    return null;
  }

  return createPortal(children, document.getElementById("__next")!);
};

createPortalを適用するDOMの準備方法

方法1: 既存のDOMを利用する

アプリケーションですでに存在しているDOM要素にポータルを適用する方法です。
上記で紹介した、__nextというidが付与されたNext.jsのデフォルトのroot divを利用する実装もこの方法に該当します。
サンプルコードは省略します。

方法2: ポータルを適用するDOMを作成する

ポータル用の要素とidをJSXに記述する方法です。

たとえばindex.tsxでポータルを表示するdiv要素を作成します。

src/pages/index.tsx

import type { NextPage } from "next";
import { Example } from "../components/Example";

const Home: NextPage = () => {
  return (
    <>
      <Example />
      <div id="portal"></div> {/* ここにポータルが描画される */}
    </>
  );
};

export default Home;

次に、以下のようにして作成した要素に対してポータルを適用します。

createPortal(children, document.getElementById("portal")!);

方法3: createPortalを実行する際にDOMの生成を行う

createPoratlでポータルを描画する際に描画用のdiv要素を作成する方法です。
以下はidportalのdiv要素を作成する例です。

src/components/shared/ExamplePortal.tsx

import { FC, useEffect, useState } from "react";
import { createPortal } from "react-dom";

const createElement = (): HTMLElement => {
  const el = document.createElement("div");
  el.setAttribute("id", "portal");
  return el;
};

export const ExamplePortal: FC = () => {
  const [showPortal, setShowPortal] = useState(false);

  useEffect(() => {
    // portalというidが付与されたdiv要素を探し、なければ作成。
    const el =
      document.querySelector<HTMLElement>("#portal") ?? createElement();

    // <div id="portal">をbodyの子要素に追加
    document.body.appendChild(el);
  }, []);

  useEffect(() => {
    setShowPortal(true);
  }, []);

  if (!showPortal) {
    return null;
  }

  return createPortal(
    <h1>Hello Portals</h1>,
    document.getElementById("portal")!
  );
};

この方法を利用するとJSX側で<div id="portal"></div>のようにしてポータル用の要素を用意しなくてよくなります。

参考: カスタムフックを利用したリファクタリング例

div要素を作成する副作用をカスタムフックとして切り出すと以下のようになります。

src/lib/hooks/usePortal.ts

import { useEffect, useState } from "react";

const createElement = (): HTMLElement => {
  const el = document.createElement("div");
  el.setAttribute("id", "portal");
  return el;
};

export const usePortal = () => {
  const [element, setElement] = useState<HTMLElement | null>(null);

  useEffect(() => {
    const el =
      document.querySelector<HTMLElement>("#portal") ?? createElement();
    document.body.appendChild(el);
    setElement(el);
  }, []);

  return element;
};

src/components/shared/ExamplePortal.tsx

import { FC, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { usePortal } from "src/lib/hooks/usePortal";

export const ExamplePortal: FC = () => {
  const [showPortal, setShowPortal] = useState(false);
  const portal = usePortal();

  useEffect(() => {
    setShowPortal(true);
  }, []);

  if (!showPortal) {
    return null;
  };

  return createPortal(<h1>Hello Portals</h1>, portal!);
};

カスタムフックの詳細解説は【React】カスタムフックの概要・メリット・使いどころで紹介しています。

さいごに

createPortalを利用したモーダル実装の詳細は【React】createPortalとContextによるモーダル実装で、トースト実装の詳細は【React】createPortalとContextによるトースト(Toast)実装で紹介しています。

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考記事