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 の組み合わせによって、柔軟なダイアログ管理が実現できました。
シンプルな設計ですが、非常に実用的で拡張性もあります。