🚪

【React】createPortalを使ったコンポーネント作成

2024/05/29に公開

こんにちは!
スペースマーケットでフロントエンドエンジニアをしているwharaguchiです。

先日createPortal APIを使用してコンポーネントを作成しましたので、今回はそちらについて書きたいと思います。

createPortalとは

createPortalとは、Reactが提供しているAPIで、DOM上の任意の場所に要素をレンダーすることができるAPIです。

https://ja.react.dev/reference/react-dom/createPortal

サンプルコードを公式ドキュメントからそのまま引用させてもらいますが、以下のように第一引数にレンダーしたもの、第二引数にレンダー先に指定して使用します。
また第三引数に一意のkeyを渡すこともできます。

import { createPortal } from 'react-dom';

<div>
  <p>This child is placed in the parent div.</p>
  {createPortal(
    <p>This child is placed in the document body.</p>,
    document.body
  )}
</div>

このAPIを使って、ページ内の任意の箇所をハイライトするコンポーネントを作成しました。

作ったもの

サンプルを作りました。
https://codesandbox.io/p/sandbox/createportal-x5hrm3

今回は、予約一覧にある予約をクリックすると、クリックされた予約に対しスタイルが付与され、オーバーレイ用のコンポーネントが差し込まれるという実装をしました。

createPortalを使用したHighlightコンポーネントと、そのコンポーネントを呼び出すReserveListPageの2つのコンポーネントがあります。

Highlight.tsx
/**
 * @param targetDom スタイルをあてる対象
 * @param insertDom targetDomをinsertする対象
 * @param isOpen ハイライトを表示するかどうか
 * @param close ハイライトを閉じるための関数
 * @param highlightStyle targetDomに付与するスタイルのオブジェクト
 */
export const Highlight: FC<HighlightProps> = ({
  targetDom,
  insertDom,
  isOpen,
  close,
  highlightStyle,
}) => {
  useEffect(() => {
    const addBaseHighlightStyle: Record<string, string> = {
      ...highlightStyle,
      position: "relative",
      "z-index": "701",
    };
    Object.keys(addBaseHighlightStyle).forEach((key) => {
      targetDom?.style.setProperty(
        key,
        isOpen ? addBaseHighlightStyle[key] : ""
      );
    });
  }, [targetDom?.style, highlightStyle, isOpen]);
  if (!isOpen) return null;
  if (!insertDom) return null;
  return createPortal(<Overlay close={close} />, insertDom);
};

const Overlay = ({ close }: { close: () => void }) => (
  <div className="overlay" onClick={close} />
);

今回は5つのpropsを受け取るようにしました。
それぞれのpropsの説明は、JSDocに書いてあるとおりです。

簡単に説明すると、isOpenがtrueであれば、スタイルを付与したい対象(targetDom)に、highlightStyleで渡ってきたスタイルを付与し、要素を追加したい対象(insertDom)を指定するといったことをしています。

次にHighlightコンポーネントを使用している、ReserveListPageコンポーネントについてです。

ReserveListPage.tsx
import { useEffect, useRef, useState } from "react";
import { Highlight } from "./Highlight";

const reserveList = [
  {
    id: 1,
    title: "予約",
    text: "予約1のテキストです。",
  },
];

export const ReserveListPage = () => {
  const [open, setOpen] = useState(false);
  const [targetIndex, setTargetIndex] = useState<number | null>(null);
  const targetRef = useRef<HTMLDivElement>(null);
  const insertRef = useRef<HTMLLIElement>(null);

  const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
  const [insertElement, setInsertElement] = useState<HTMLElement | null>(null);

  useEffect(() => {
    setTargetElement(targetRef.current);
    setInsertElement(insertRef.current);
  }, [targetIndex]);

  const handleOpen = (e) => {
    setOpen(true);
    setTargetIndex(e);
  };

  return (
    <div className="wrapper">
      <div className="inner">
        <h1>予約一覧</h1>
        <ul className="list">
          {reserveList.map((reserve, index) => {
            const isEditTargetReservation = index === targetIndex;
            return (
              <li
                className="list-item"
                key={reserve.id}
                ref={isEditTargetReservation ? insertRef : undefined}
              >
                <div
                  className="list-item-inner"
                  ref={isEditTargetReservation ? targetRef : undefined}
                >
                  <button onClick={() => handleOpen(index)}>
                    {reserve.title}
                    {reserve.id}
                  </button>
                  {isEditTargetReservation && open && <p>{reserve.text}</p>}
                </div>
              </li>
            );
          })}
        </ul>
      </div>
      <Highlight
        targetDom={targetElement}
        insertDom={insertElement}
        isOpen={open}
        close={() => {
          setOpen(false);
        }}
        highlightStyle={{
          "background-color": "white",
          border: "2px solid red",
        }}
      />
    </div>
  );
};

targetDominsertDomuseRefを使用して取得し、それぞれHighlightコンポーネントに渡すようにしています。

この状態で「予約1」をクリックすると、以下のような形でHighlightコンポーネント内で定義されたOverlayコンポーネントがli.list-itemに差し込まれ、div.list-item-innerに対し、highlightStyleで定義したスタイルが付与されます。

サンプル

<li class="list-item">
  <!-- highlightStyleで指定したstyleが付与される -->
  <div class="list-item-inner" style="background-color: white; border: 2px solid red; position: relative; z-index: 701;">
    <button>予約1</button>
    <p>予約1のテキストです。</p>
  </div>
  <!-- ここにoverlayが差し込まれる -->
  <div class="overlay"></div>
</li>

最後に

いかがだったでしょうか?
createPortalを使用することで、モーダルを作成したり、アプリでよく見かけるウォークスルーのような表現もできると思います。

みなさんもぜひ使ってみてください!

宣伝

スペースマーケットでは現在エンジニアを募集しております!
ちょっと話を聞いてみたいといったようなカジュアルな面談でも構いませんので、ご興味のある方は是非ご応募お待ちしております!

GitHubで編集を提案
スペースマーケット Engineer Blog

Discussion