React Portals の実務活用ガイド|モーダル・ツールチップ実装パターン

React / Next.js

React Portals の実務活用ガイド|実装パターンと注意点

はじめに

React を使ったプロジェクトで、モーダルやドロップダウンメニューを実装する際に「親要素の overflow:hidden に影響されてしまう」「z-index の管理が煩雑」といった問題に直面したことはありませんか?そんな時に活躍するのが React Portals です。

本記事では、React Portals の基本から実務で即座に使えるコード例まで、実践的な解説を行います。

React Portals とは

React Portals は、コンポーネントをその親要素とは異なる DOM ノードにレンダリングする機能です。通常、React コンポーネントは親要素の内部に DOM が作成されますが、Portals を使用することで、DOM ツリーの別の場所(通常は body 直下)にレンダリングできます。

簡潔な説明:

  • コンポーネントのロジックは元の場所に保つ
  • 実際の DOM は指定した別の場所にレンダリング
  • CSS のスタイルスコーピングや親要素の制約から解放される

業務で Portals が活躍するシーン

1. グローバルモーダルダイアログ

複雑な階層構造を持つページで、任意の場所からモーダルを開く必要がある場合、Portals を使うと z-index や overflow の管理が格段に楽になります。

2. ツールチップ・ポップオーバー

親要素が overflow:hidden を持つ場合、通常の方法ではツールチップがクリップされてしまいます。Portals で回避できます。

3. トースト通知・アラート

画面上部に固定表示される通知は、どのコンポーネント階層からでも制御したいケースが多いです。

4. ドラッグ & ドロップの幽霊画像

ドラッグ中のプレビュー画像を body 直下にレンダリングすることで、親要素の制約を受けなくなります。

実装コード:基本的な使い方

Step 1: HTML に Portal のマウントポイントを追加

<!-- public/index.html -->
<body>
  <div id="root"></div>
  <div id="modal-root"></div>
  <div id="tooltip-root"></div>
</body>

Step 2: Portal ラッパーコンポーネント

// components/Portal.tsx
import React, { ReactNode, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';

interface PortalProps {
  children: ReactNode;
  containerId?: string;
}

export const Portal: React.FC<PortalProps> = ({
  children,
  containerId = 'modal-root',
}) => {
  const [mounted, setMounted] = useState(false);

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

  if (!mounted) return null;

  const container = document.getElementById(containerId);
  if (!container) {
    console.warn(`Container with id "${containerId}" not found`);
    return null;
  }

  return createPortal(children, container);
};

export default Portal;

Step 3: モーダルコンポーネント

// components/Modal.tsx
import React, { ReactNode, useCallback } from 'react';
import Portal from './Portal';
import './Modal.css';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
  size?: 'small' | 'medium' | 'large';
}

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
  size = 'medium',
}) => {
  const handleBackdropClick = useCallback(
    (e: React.MouseEvent) => {
      if (e.target === e.currentTarget) {
        onClose();
      }
    },
    [onClose]
  );

  const handleEscKey = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
      }
    },
    [onClose]
  );

  React.useEffect(() => {
    if (!isOpen) return;

    document.addEventListener('keydown', handleEscKey);
    document.body.style.overflow = 'hidden';

    return () => {
      document.removeEventListener('keydown', handleEscKey);
      document.body.style.overflow = 'unset';
    };
  }, [isOpen, handleEscKey]);

  if (!isOpen) return null;

  return (
    <Portal containerId="modal-root">
      <div className="modal-backdrop" onClick={handleBackdropClick}>
        <div className={`modal modal-${size}`}>
          <div className="modal-header">
            <h2>{title}</h2>
            <button
              className="modal-close"
              onClick={onClose}
              aria-label="Close modal"
            >
              ×
            </button>
          </div>
          <div className="modal-body">{children}</div>
        </div>
      </div>
    </Portal>
  );
};

export default Modal;

Step 4: CSS スタイリング

/* components/Modal.css */
.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  animation: fadeIn 0.3s ease-out;
}

.modal {
  background: white;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  max-height: 90vh;
  overflow-y: auto;
  animation: slideUp 0.3s ease-out;
}

.modal-small {
  width: 90%;
  max-width: 400px;
}

.modal-medium {
  width: 90%;
  max-width: 600px;
}

.modal-large {
  width: 90%;
  max-width: 900px;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px;
  border-bottom: 1px solid #eee;
}

.modal-header h2 {
  margin: 0;
  font-size: 20px;
  font-weight: 600;
}

.modal-close {
  background: none;
  border: none;
  font-size: 28px;
  cursor: pointer;
  color: #999;
  transition: color 0.2s;
  padding: 0;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.modal-close:hover {
  color: #333;
}

.modal-body {
  padding: 20px;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes slideUp {
  from {
    transform: translateY(20px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

Step 5: 使用例

// pages/UserSettings.tsx
import React, { useState } from 'react';
import Modal from '../components/Modal';

export const UserSettings: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsModalOpen(true)}>
        設定を開く
      </button>

      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title="ユーザー設定"
        size="medium"
      >
        <form>
          <div>
            <label>メールアドレス</label>
            <input type="email" />
          </div>
          <div>
            <label>パスワード</label>
            <input type="password" />
          </div>
          <button type="submit">保存</button>
        </form>
      </Modal>
    </>
  );
};

実務でよく使う応用パターン

パターン1: ツールチップコンポーネント

// components/Tooltip.tsx
import React, { useState, useRef, ReactNode } from 'react';
import Portal from './Portal';
import './Tooltip.css';

interface TooltipProps {
  content: string | ReactNode;
  children: ReactNode;
  position?: 'top' | 'bottom' | 'left' | 'right';
  delay?: number;
}

export const Tooltip: React.FC<TooltipProps> = ({
  content,
  children,
  position = 'top',
  delay = 300,
}) => {
  const [isVisible, setIsVisible] = useState(false);
  const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  const handleMouseEnter = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      if (!triggerRef.current) return;

      const rect = triggerRef.current.getBoundingClientRect();
      const tooltipWidth = 200;
      const tooltipHeight = 40;
      const offset = 8;

      let top = 0;
      let left = 0;

      switch (position) {
        case 'top':
          top = rect.top - tooltipHeight - offset;
          left = rect.left + rect.width / 2 - tooltipWidth / 2;
          break;
        case 'bottom':
          top = rect.bottom + offset;
          left = rect.left + rect.width / 2 - tooltipWidth / 2;
          break;
        case 'left':
          top = rect.top + rect.height / 2 - tooltipHeight / 2;
          left = rect.left - tooltipWidth - offset;
          break;
        case 'right':
          top = rect.top + rect.height / 2 - tooltipHeight / 2;
          left = rect.right + offset;
          break;
      }

      setTooltipPosition({ top, left });
      setIsVisible(true);
    }, delay);
  };

  const handleMouseLeave = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    setIsVisible(false);
  };

  return (
    <>
      <div
        ref={triggerRef}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        style={{ display: 'inline-block' }}
      >
        {children}
      </div>

      {isVisible && (
        <Portal containerId="tooltip-root">
          <div
            className={`tooltip tooltip-${position}`}
            style={{
              top: `${tooltipPosition.top}px`,
              left: `${tooltipPosition.left}px`,
            }}
          >
            {content}
          </div>
        </Portal>
      )}
    </>
  );
};
/* components/Tooltip.css */
.tooltip {
  position: fixed;
  background-color: #333;
  color: white;
  padding: 8px 12px;
  border-radius: 4px;
  font-size: 14px;
  white-space: nowrap;
  z-index: 999;
  pointer-events: none;
  animation: tooltipFadeIn 0.2s ease-out;
}

@keyframes tooltipFadeIn {
  from {
    opacity: 0;
    transform: scale(0.9);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

パターン2: トースト通知システム

// hooks/useToast.ts
import { useCallback, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import Toast from '../components/Toast';

interface ToastMessage {
  id: string;
  message: string;
  type: 'success' | 'error' | 'info' | 'warning';
  duration?: number;
}

const toastQueue: ToastMessage[] = [];
const toastCallbacks: ((messages: ToastMessage[]) => void)[] = [];

export const useToast = () => {
  const idRef = useRef(0);

  const show = useCallback(
    (
      message: string,
      type: 'success' | 'error' | 'info' | 'warning' = 'info',
      duration = 3000
    ) => {
      const id = `toast-${idRef.current++}`;
      const toast: ToastMessage = { id, message, type, duration };

      toastQueue.push(toast);
      toastCallbacks.forEach((cb) => cb([...toastQueue]));

      if (duration > 0) {
        setTimeout(() => {
          const index = toastQueue.findIndex((t) => t.id === id);
          if (index !== -1) {
            toastQueue.splice(index, 1);
            toastCallbacks.forEach((cb) => cb([...toastQueue]));
          }
        }, duration);
      }

      return id;
    },
    []
  );

  const remove = useCallback((id: string) => {
    const index = toastQueue.findIndex((t) => t.id === id);
    if (index !== -1) {
      toastQueue.splice(index, 1);
      toastCallbacks.forEach((cb) => cb([...toastQueue]));
    }
  }, []);

  const subscribe = useCallback((callback: (messages: ToastMessage[]) => void) => {
    toastCallbacks.push(callback);
    return () => {
      const index = toastCallbacks.indexOf(callback);
      if (index !== -1) {
        toastCallbacks.splice(index, 1);
      }
    };
  }, []);

  return { show, remove, subscribe };
};

export const initializeToastContainer = () => {
  const container = document.getElementById('toast-root');
  if (!container) return;

  const root = createRoot(container);
  const { subscribe } = useToast();

  subscribe((messages) => {
    root.render(
      <div className="toast-container">
        {messages.map((toast) => (
          <Toast key={toast.id} {...toast} />
        ))}
      </div>
    );
  });
};
// components/Toast.tsx
import React from 'react';
import './Toast.css';

interface ToastProps {
  id: string;
  message: string;
  type: 'success' | 'error' | 'info' | 'warning';
}

export const Toast: React.FC<ToastProps> = ({ message, type }) => {
  const iconMap = {
    success: '✓',
    error: '✕',
    info: 'ℹ',
    warning: '!',
  };

  return (
    <div className={`toast toast-${type}`}>
      <span className="toast-icon">{iconMap[type]}</span>
      <span className="toast-message">{message}</span>
    </div>
  );
};

export default Toast;

パターン3: コンテキストを使った Portals 管理

// context/PortalContext.tsx
import React, { createContext, useCallback, useState, ReactNode } from 'react';

interface DialogItem {
  id: string;
  component: ReactNode;
}

interface PortalContextType {
  dialogs: DialogItem[];
  addDialog: (id: string, component: ReactNode) => void;
  removeDialog: (id: string) => void;
}

export const PortalContext = createContext<PortalContextType | undefined>(
  undefined
);

export const PortalProvider: React.FC<{ children: ReactNode }> = ({
  children,
}) => {
  const [dialogs, setDialogs] = useState<DialogItem[]>([]);

  const addDialog = useCallback((id: string, component: ReactNode) => {
    setDialogs((prev) => [...prev, { id, component }]);
  }, []);

  const removeDialog = useCallback((id: string) => {
    setDialogs((prev) => prev.filter((dialog) => dialog.id !== id));
  }, []);

  return (
    <PortalContext.Provider value={{ dialogs, addDialog, removeDialog }}>
      {children}
      <div id="dialog-root">
        {dialogs.map((dialog) => (
          <React.Fragment key={dialog.id}>{dialog.component}</React.Fragment>
        ))}
      </div>
    </PortalContext.Provider>
  );
};

export const usePortal = () => {
  const context = React.useContext(PortalContext);
  if (!context) {
    throw new Error('usePortal must be used within PortalProvider');
  }
  return context;
};

注意点と落とし穴

1. SSR(サーバーサイドレンダリング)での注意

Portals はクライアント側のみで機能します。SSR を使用している場合は、hydration エラーを避けるため必ず useEffect 内で Portal をマウントしてください。

// 良い例
const MyComponent = () => {
  const [mounted, setMounted] = useState(false);

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

  if (!mounted) return null;

  return <Portal>...</Portal>;
};

2. イベント委譲の考慮

Portal 内のイベント(クリック、キー入力)は、実際の DOM 位置ではなく React コンポーネントツリー内で伝播します。ただし、body への直接リスナー(scroll、resize など)は別途処理が必要な場合があります。

3. メモリリークの防止

Portals 内でタイマーやリスナーを設定した場合、必ずクリーンアップしてください。

useEffect(() => {
  const timer = setTimeout(() => {
    setIsVisible(false);
  }, 3000);

  return () => clearTimeout(timer); // クリーンアップ必須
}, []);

4. 複数の Portal コンテナの管理

プロジェクトが大きくなると、複数の Portal コンテナ(modal-root、tooltip-root、notification-root など)が必要になります。これらを一元管理する仕組みを最初から用意しておくと後々の拡張が楽です。

5. z-index の競合

複数のコンポーネントが Portal を使う場合、z-index を適切に設定してください。

/* 推奨:階層を明確にする */
.notification { z-index: 900; }
.tooltip { z-index: 950; }
.modal { z-index: 1000; }
.dropdown { z-index: 850; }

実務コードの完全例(統合版)

// App.tsx
import React from 'react';
import { PortalProvider } from './context/PortalContext';
import { initializeToastContainer } from './hooks/useToast';
import UserSettings from './pages/UserSettings';
import './App.css';

function App() {
  React.useEffect(() => {
    initializeToastContainer();
  }, []);

  return (
    <PortalProvider>
      <div className="app">
        <header>
          <h1>My Application</h1>
        </header>
        <main>
          <UserSettings />
        </main>
      </div>
    </PortalProvider>
  );
}

export default App;
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
    <div id="modal-root"></div>
    <div id="tooltip-root"></div>
    <div id="toast-root"></div>
    <div id="dialog-root"></div>
  </body>
</html>

まとめ

React Portals は、一見すると「DOM をどこにレンダリングするか」という単純な機能に見えますが、実務では以下のような大きな価値を提供します:

  • UI の自由度向上:親要素の overflow や z-index の制約から解放される
  • コンポーネントの再利用性向上:深い階層のコンポーネントからも同じインターフェースでダイアログやツールチップが使える
  • 管理の一元化:グローバルな UI 要素を効率的に管理できる
  • ユーザーエクスペリエンスの向上:スムーズなアニメーションと予測可能な動作

本記事で紹介したパターンを基に、プロジェクトの要件に合わせて拡張・カスタマイズすれば、堅牢で保守性の高い UI システムが構築できます。特に中大規模なプロジェクトでは、Portals の活用が開発効率と品質の向上に直結します。

最後のポイント:Portals は「どこにレンダリングするか」だけを管理し、スタイルやアニメーション、ビジネスロジックはいつも通り実装すればよい。この分離によって、コンポーネント設計がより明確になり、テストも容易になるという副次効果も期待できます。

タイトルとURLをコピーしました