ant.seal.dev

App Router × PubSub × Portal でダイアログ管理機能を作った

2025年08月04日

🎯 やりたかったこと

Dialogをここに定義する構造になっていたのでグローバルにダイアログを開ける仕組みを構築したかった。

要件は以下の通り:

  • App Router に対応(app/ ディレクトリ構成)
  • グローバルなダイアログ管理
  • 呼び出し元と表示ロジックの疎結合
  • Portal 経由での描画
  • 任意の場所から dialog.open(...) みたいに使いたい

🧩 技術選定

  • App Router:ページ構成は app/ ベース
  • PubSub パターン:コンポーネント間の疎結合な通信
  • Portal:DOM の外にダイアログを出す
  • Context API:Provider でグローバルに管理

🛠 pubsub.ts - シンプルな PubSub 実装

type Callback<T> = (payload: T) => void;

export class PubSub<Topic extends string, Payload = void> {
  private subscribers: { [K in Topic]?: Callback<Payload>[] } = {};

  subscribe(topic: Topic, callback: Callback<Payload>) {
    if (!this.subscribers[topic]) {
      this.subscribers[topic] = [];
    }
    this.subscribers[topic]\\!.push(callback);
    return () => {
      this.subscribers[topic] = this.subscribers[topic]\\!.filter(cb => cb !== callback);
    };
  }

  publish(topic: Topic, payload: Payload) {
    this.subscribers[topic]?.forEach(cb => cb(payload));
  }
}

🧪 DialogProvider.tsx - Provider + PubSub 連携

'use client';

import { createContext, useContext, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { PubSub } from '../lib/pubsub';
import { ConfirmDialog } from './ConfirmDialog';

type DialogName = 'confirm';
type DialogPayload = { message: string; onConfirm: () => void };

const dialogPubSub = new PubSub<DialogName, DialogPayload>();

const DialogContext = createContext({
  openConfirm: (payload: DialogPayload) => {},
});

export const useDialog = () => useContext(DialogContext);

export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
  const [dialog, setDialog] = useState<DialogPayload | null>(null);

  useEffect(() => {
    const unsubscribe = dialogPubSub.subscribe('confirm', (payload) => {
      setDialog(payload);
    });

    return () => unsubscribe();
  }, []);

  return (
    <DialogContext.Provider value={{
      openConfirm: (payload) => dialogPubSub.publish('confirm', payload),
    }}>
      {children}
      {typeof window !== 'undefined' && dialog &&
        createPortal(
          <ConfirmDialog
            message={dialog.message}
            onConfirm={() => {
              dialog.onConfirm();
              setDialog(null);
            }}
            onCancel={() => setDialog(null)}
          />,
          document.body
        )}
    </DialogContext.Provider>
  );
};

🖼 ConfirmDialog.tsx - 単純な確認ダイアログ

export const ConfirmDialog = ({
  message,
  onConfirm,
  onCancel,
}: {
  message: string;
  onConfirm: () => void;
  onCancel: () => void;
}) => {
  return (
    <div className="fixed inset-0 bg-black-50 flex items-center justify-center z-50">
      <div className="bg-white p-4 rounded shadow">
        <p>{message}</p>
        <div className="mt-4 flex justify-end gap-2">
          <button onClick={onCancel}>キャンセル</button>
          <button onClick={onConfirm}>OK</button>
        </div>
      </div>
    </div>
  );
};

📍 Layout に DialogProvider を組み込む

import { DialogProvider } from '../components/DialogProvider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <DialogProvider>
          {children}
        </DialogProvider>
      </body>
    </html>
  );
}

💡 使い方 - 任意のコンポーネントから呼び出し

'use client';

import { useDialog } from './DialogProvider';

export const SampleButton = () => {
  const { openConfirm } = useDialog();

  return (
    <button
      onClick={() => {
        openConfirm({
          message: '本当に削除しますか?',
          onConfirm: () => {
            console.log('削除しました');
          },
        });
      }}
    >
      削除する
    </button>
  );
};

✅ 良かった点

  • 疎結合で再利用性が高い
  • Portal によってレイアウトの影響を受けない
  • Dialog の種類を増やすのも簡単(PubSub の topic を追加するだけ)

🚀 まとめ

Next.js App Router 環境でも、Context + PubSub + Portal の組み合わせによって、柔軟なダイアログ管理が実現できました。

シンプルな設計ですが、非常に実用的で拡張性もあります。