React 速習チュートリアル

React Portals

1. React Portalsとは?

React Portalsは、親コンポーネントのDOM階層の外側にHTMLをレンダリングするための手段を提供します。

これは、モーダル、ツールチップ、ダイアログなど、コンテナのレイアウト(CSSの制約など)を飛び越えて表示させる必要があるコンポーネントにおいて特に有用です。

Portalは react-dom パッケージに含まれるReactのメソッドです。通常、コンポーネントから返されるHTML要素は親コンポーネントの子要素として配置されます。

createPortalメソッドを使用しない場合の例:

function myChild() {
  return (
    <div>
      ようこそ
    </div>
  );
}

しかし、createPortal メソッドを使用することで、HTMLを親コンポーネントの子要素としてではなく、DOM階層外の任意の場所にレンダリングすることが可能になります。

2. createPortalの基本

createPortal メソッドを使用した実装は以下の通りです。

実装例:

import { createPortal } from 'react-dom';

function myChild() {
  return createPortal(
    <div>
      ようこそ
    </div>,
    document.body
  );
}

2.1 構文(Syntax)

import { createPortal } from 'react-dom';

createPortal(children, domNode)
  • children: 要素、文字列、フラグメント(Fragment)など、レンダリング可能なすべてのReactコンテンツ。
  • domNode: ポータルを挿入する先のDOM要素。

3. Portalを使用したモーダルの作成

前述の通り、Portalはモーダル、ツールチップ、ダイアログといった「コンテナのレイアウト制約を突破」する必要があるコンポーネントに最適です。

以下は、親コンポーネントのDOM階層外にレンダリングされるモーダルコンポーネントの実装例です。

実装例:

import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { createPortal } from 'react-dom';

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;

  return createPortal(
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      backgroundColor: 'rgba(0, 0, 0, 0.5)',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center'
    }}>
      <div style={{
        background: 'white',
        padding: '20px',
        borderRadius: '8px'
      }}>
        {children}
        <button onClick={onClose}>閉じる</button>
      </div>
    </div>,
    document.body
  );
}

function MyApp() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <h1>マイ・アプリ</h1>
      <button onClick={() => setIsOpen(true)}>
        モーダルを開く
      </button>

      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <h2>モーダルのコンテンツ</h2>
        <p>このコンテンツはMyAppコンポーネントの外側にレンダリングされています!</p>
      </Modal>
    </div>
  );
}

createRoot(document.getElementById('root')).render(
  <MyApp />
);

3.1 コードの解説

  1. インポート: react-dom から createPortal を、react から状態管理用の useState をインポートします。
  2. Modalコンポーネント: createPortal を使用して、そのコンテンツを document.body 要素に直接レンダリングします。

4. なぜPortalを使用するのか

Portalは以下のようなケースで非常に役立ちます:

  • モーダルやダイアログ
  • ツールチップ
  • フローティングメニュー
  • 通知(Notification)

特に、親コンポーネントに以下の設定がある場合、UI要素がコンテナ内に閉じ込められてしまうのを防ぐためにPortalが必要です:

  • overflow: hidden
  • z-index の競合
  • 複雑なポジショニング(配置)要件

5. Portalにおけるイベントバブリング

PortalがDOMツリーの異なる場所にコンテンツをレンダリングしたとしても、Portal内のコンテンツから発生したイベントは、あたかもPortalが存在しないかのように Reactコンポーネントツリーに従ってバブリング します。

例えば、Portal内のボタンがクリックされた場合、そのイベントはReactの階層上の親コンポーネントへと伝播し、親のイベントハンドラーをトリガーします。

実装例:

import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { createPortal } from 'react-dom';

function PortalButton({ onClick, children }) {
  return createPortal(
    <button 
      onClick={onClick}
      style={{
        position: 'fixed',
        bottom: '20px',
        right: '20px',
        padding: '10px',
        background: 'blue',
        color: 'white'
      }}>
      {children}
    </button>,
    document.body
  );
}

function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  return (
    <div
      style={{
        padding: '20px',
        border: '2px solid black',
        margin: '20px'
      }}
      onClick={() => {
        // 親要素のクリックハンドラー
        setCount1(c => c + 1);
      }}>
      <h2>Divのクリック回数: {count1}</h2>
      <h2>ボタンのクリック回数: {count2}</h2>      
      <p>このフローティングボタンはPortalを使用して枠の外側にレンダリングされています。
          しかし、クリックイベントはこの親のdivまでバブリングされます!</p>
      <p>div要素自体をクリックして、カウントが増えるのも確認してみてください。</p>
      
      <PortalButton
        onClick={(e) => {
          // これが最初に実行されます
          setCount2(c => c + 1);
        }}>
        フローティングボタン
      </PortalButton>
    </div>
  );
}

createRoot(document.getElementById('root')).render(
  <App />
);

5.1 コードの解説

この例では以下の挙動が確認できます:

  1. PortalButton はPortalを用いて画面の右下に固定(fixed)表示されます。
  2. DOM上では親の <div> 外に存在しますが、ボタンをクリックすると:
    • まず、ボタン自身の onClick ハンドラーが実行されます。
    • 次に、親である divonClick ハンドラーが実行されます。

これは、イベントバブリングが物理的な「DOM階層」ではなく、Reactの「コンポーネント階層」に従って機能することを証明しています。