
【Next.js】管理画面で使えるConfirmModalを作成
こんにちは、ライトです。
これまで、管理画面で使うコンポーネントとして、以下のようなものを作成してきました。
管理画面レイアウト
StatusBadge
DataTable
Pagination
SearchFilterBar
RowActionMenu
前回は、DataTable の各行で使える RowActionMenu を作成しました。
ただ、削除のような操作は、クリックした瞬間に実行するのは少し危険です。
管理画面では、確認を挟みたい場面がよくあります。
そこで今回は、削除確認や更新確認などで使える、汎用的な ConfirmModal を作成します。
以前作成したModalとの違い
以前、汎用的な Modal コンポーネントを作成しました。
Modal は、自由な内容を表示するための「枠」として使うものでした。
<Modal isOpen={isOpen} onClose={onClose} title="詳細">
<p>ここに自由な内容を表示します。</p>
</Modal>詳細表示、フォーム、画像プレビュー、お知らせなど、幅広い用途に使えるのが Modal の役割です。
一方で、今回作成する ConfirmModal は、ユーザーに確認してから処理を実行するための専用コンポーネントです。
用途はもう少し限定されます。
削除確認
更新確認
公開確認
停止確認
送信確認
つまり、役割としては次のように分けます。
Modal
汎用的な表示枠ConfirmModal
確認操作専用のモーダル
ConfirmModal では、タイトル、説明文、キャンセルボタン、実行ボタン、危険操作の見た目、ローディング状態などをあらかじめ持たせます。
自由度よりも、確認操作としての分かりやすさを優先します。
今回作成するもの
今回は、以下のように使える ConfirmModal を作成します。
<ConfirmModal
isOpen={isOpen}
title="ユーザーを削除しますか?"
description="この操作は取り消せません。"
confirmLabel="削除する"
cancelLabel="キャンセル"
variant="danger"
isLoading={isDeleting}
onConfirm={handleDelete}
onClose={closeModal}
/>削除だけでなく、更新確認にも使えるようにします。
<ConfirmModal
isOpen={isOpen}
title="変更内容を保存しますか?"
description="入力した内容で情報を更新します。"
confirmLabel="保存する"
variant="default"
isLoading={isSaving}
onConfirm={handleSave}
onClose={closeModal}
/>今回の ConfirmModal では、以下に対応します。
タイトルを表示する
説明文を表示する
childrenで補足情報を表示できる
キャンセルボタンを表示する
実行ボタンを表示する
danger表示に対応する
loading状態に対応する
背景クリックで閉じる
Escキーで閉じる
loading中は閉じない
完成形
以下のような確認モーダルを作成します。

削除確認の場合は赤系、更新確認の場合はシアン系のボタンで表示します。
ファイル構成
今回は、以下の構成で作成します。
components/
ui/
ConfirmModal.tsx
app/
page.tsxConfirmModal は、管理画面内のさまざまな操作で使える汎用UIなので、components/ui/ に作成します。
ConfirmModalのprops設計
まず、propsを考えます。
type ConfirmModalVariant = "default" | "danger";
type ConfirmModalProps = {
isOpen: boolean; # モーダルを表示しているかどうか
title: string; # 確認内容のタイトル
description?: string; # 補足説明
confirmLabel?: string; # 実行ボタンの文言
cancelLabel?: string; # キャンセルボタンの文言
variant?: ConfirmModalVariant; # default もしくは danger
isLoading?: boolean; # 実行中かどうか
children?: React.ReactNode; # 削除対象などの補足表示
onConfirm: () => void; # 実行ボタンを押したときの処理
onClose: () => void; # 閉じる処理
};variant は、通常操作と危険操作を分けるために使います。
default
保存、更新、公開などdanger
削除、停止、取り消しなど
ConfirmModal.tsxを作成する
それでは、ConfirmModal.tsx を作成します。
components/ui/ConfirmModal.tsx
"use client";
import { useEffect } from "react";
type ConfirmModalVariant = "default" | "danger";
type ConfirmModalProps = {
isOpen: boolean;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: ConfirmModalVariant;
isLoading?: boolean;
children?: React.ReactNode;
onConfirm: () => void;
onClose: () => void;
};
const confirmButtonClasses: Record<ConfirmModalVariant, string> = {
default: "bg-primary-500 text-white hover:bg-primary-700",
danger: "bg-rose-600 text-white hover:bg-rose-700",
};
const iconClasses: Record<ConfirmModalVariant, string> = {
default: "border-primary-100 bg-primary-50 text-primary-600",
danger: "border-rose-100 bg-rose-50 text-rose-600",
};
export default function ConfirmModal({
isOpen,
title,
description,
confirmLabel = "実行する",
cancelLabel = "キャンセル",
variant = "default",
isLoading = false,
children,
onConfirm,
onClose,
}: ConfirmModalProps) {
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !isLoading) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, isLoading, onClose]);
useEffect(() => {
if (!isOpen) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
if (!isOpen) {
return null;
}
const handleClose = () => {
if (isLoading) {
return;
}
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
<button
type="button"
className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
onClick={handleClose}
aria-label="モーダルを閉じる"
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
className="relative w-full max-w-md rounded-xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/10"
>
<div className="flex gap-4">
<div
className={[
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full border",
iconClasses[variant],
].join(" ")}
aria-hidden
>
<span className="text-lg font-semibold">
{variant === "danger" ? "!" : "✓"}
</span>
</div>
<div className="min-w-0 flex-1">
<h2
id="confirm-modal-title"
className="text-lg font-semibold tracking-tight text-slate-950"
>
{title}
</h2>
{description && (
<p className="mt-2 text-sm leading-6 text-slate-600">
{description}
</p>
)}
</div>
</div>
{children && <div className="mt-5">{children}</div>}
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={handleClose}
disabled={isLoading}
className="h-11 rounded-lg border border-slate-200 bg-white px-4 text-sm font-medium text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={isLoading}
className={[
"h-11 rounded-lg px-4 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-70",
confirmButtonClasses[variant],
].join(" ")}
>
{isLoading ? "処理中..." : confirmLabel}
</button>
</div>
</div>
</div>
);
}これで、確認操作に使える ConfirmModal ができました。
danger表示に対応する
variant によって、実行ボタンやアイコンの色を切り替えています。
type ConfirmModalVariant = "default" | "danger";ボタンの色は以下のように定義しています。
const confirmButtonClasses: Record<ConfirmModalVariant, string> = {
default: "bg-primary-500 text-white hover:bg-primary-700",
danger: "bg-rose-600 text-white hover:bg-rose-700",
};削除確認の場合は、variant="danger" を指定します。
<ConfirmModal
variant="danger"
confirmLabel="削除する"
...
/>これにより、削除ボタンが赤系になります。
削除
停止
取り消し
のような危険な操作では、通常操作と見た目を分けると分かりやすくなります。
childrenで補足情報を表示する
ConfirmModal では、children を使えるようにしています。
{children && <div className="mt-5">{children}</div>}これにより、削除対象や更新内容などを自由に表示できます。
たとえば、削除確認では次のように使えます。
<ConfirmModal
isOpen={isOpen}
title="ユーザーを削除しますか?"
description="この操作は取り消せません。"
confirmLabel="削除する"
variant="danger"
onConfirm={handleDelete}
onClose={closeModal}
>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
削除対象:山田 太郎
</div>
</ConfirmModal>削除対象を明示しておくと、誤操作を防ぎやすくなります。
loading状態に対応する
削除や更新の処理は、API通信になることが多いです。
そのため、処理中の状態を isLoading で渡せるようにしています。
isLoading?: boolean;処理中は、ボタンを無効化します。
disabled={isLoading}実行ボタンの文言も切り替えます。
{isLoading ? "処理中..." : confirmLabel}削除中や更新中に何度もボタンを押せると、二重実行につながる可能性があります。
そのため、処理中はボタンをdisabledにしておくと安心です。
loading中は閉じない
今回の実装では、isLoading 中はモーダルを閉じないようにしています。
const handleClose = () => {
if (isLoading) {
return;
}
onClose();
};背景クリックでも、handleClose を使っています。
<button
type="button"
className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
onClick={handleClose}
aria-label="モーダルを閉じる"
/>Escキーでも、isLoading 中は閉じないようにしています。
if (event.key === "Escape" && !isLoading) {
onClose();
}削除中にモーダルを閉じられると、処理が成功したのか失敗したのか分かりにくくなることがあります。
そのため、処理中は閉じられないようにしています。
背景スクロールを止める
モーダル表示中は、背景がスクロールしないようにしています。
useEffect(() => {
if (!isOpen) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);モーダルの背後にある画面がスクロールできると、操作感が悪くなることがあります。
そのため、モーダル表示中だけ body の overflow を hidden にしています。
閉じるときには、元の状態に戻します。
RowActionMenuからConfirmModalを開く
次に、前回作成した RowActionMenu から ConfirmModal を開くようにします。
ユーザー一覧を例にします。
app/page.tsx
"use client";
import { useState } from "react";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import ConfirmModal from "@/components/ui/ConfirmModal";
import DataTable, { DataTableColumn } from "@/components/ui/DataTable";
import RowActionMenu from "@/components/ui/RowActionMenu";
import StatusBadge from "@/components/ui/StatusBadge";
type User = {
id: number;
name: string;
email: string;
role: "admin" | "editor" | "viewer";
status: "active" | "pending" | "suspended";
};
const initialUsers: User[] = [
{
id: 1,
name: "山田 太郎",
email: "yamada@example.com",
role: "admin",
status: "active",
},
{
id: 2,
name: "佐藤 花子",
email: "sato@example.com",
role: "editor",
status: "pending",
},
{
id: 3,
name: "田中 一郎",
email: "tanaka@example.com",
role: "viewer",
status: "suspended",
},
];
function renderStatus(status: User["status"]) {
switch (status) {
case "active":
return <StatusBadge variant="success">有効</StatusBadge>;
case "pending":
return <StatusBadge variant="warning">承認待ち</StatusBadge>;
case "suspended":
return <StatusBadge variant="danger">停止中</StatusBadge>;
}
}
function renderRole(role: User["role"]) {
switch (role) {
case "admin":
return "管理者";
case "editor":
return "編集者";
case "viewer":
return "閲覧者";
}
}
export default function Page() {
const [users, setUsers] = useState<User[]>(initialUsers);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const openDeleteModal = (user: User) => {
setSelectedUser(user);
setIsDeleteModalOpen(true);
};
const closeDeleteModal = () => {
if (isDeleting) {
return;
}
setIsDeleteModalOpen(false);
setSelectedUser(null);
};
const handleDelete = async () => {
if (!selectedUser) {
return;
}
setIsDeleting(true);
await new Promise((resolve) => setTimeout(resolve, 800));
setUsers((currentUsers) =>
currentUsers.filter((user) => user.id !== selectedUser.id)
);
setIsDeleting(false);
setIsDeleteModalOpen(false);
setSelectedUser(null);
};
const columns: DataTableColumn<User>[] = [
{
key: "name",
header: "名前",
render: (user) => (
<div>
<div className="font-medium text-slate-900">{user.name}</div>
<div className="mt-1 text-xs text-slate-400">ID: {user.id}</div>
</div>
),
},
{
key: "email",
header: "メールアドレス",
render: (user) => (
<span className="text-slate-600">{user.email}</span>
),
},
{
key: "role",
header: "権限",
render: (user) => (
<span className="text-slate-600">{renderRole(user.role)}</span>
),
},
{
key: "status",
header: "ステータス",
render: (user) => renderStatus(user.status),
},
{
key: "actions",
header: "",
className: "w-16 text-right",
render: (user) => (
<RowActionMenu
label={`${user.name}の操作`}
actions={[
{
label: "詳細を見る",
onClick: () => {
console.log("詳細:", user.id);
},
},
{
label: "編集する",
onClick: () => {
console.log("編集:", user.id);
},
},
{
label: "削除する",
variant: "danger",
onClick: () => {
openDeleteModal(user);
},
},
]}
/>
),
},
];
return (
<DashboardLayout>
<div className="space-y-6">
<section>
<p className="text-sm font-medium text-primary-700">Users</p>
<h2 className="mt-1 text-2xl font-semibold text-slate-950">
ユーザー一覧
</h2>
<p className="mt-2 max-w-2xl text-sm leading-7 text-slate-600">
RowActionMenuからConfirmModalを開き、削除確認を行うサンプルです。
</p>
</section>
<DataTable
columns={columns}
data={users}
getRowKey={(user) => user.id}
emptyMessage="ユーザーが見つかりません。"
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
title="ユーザーを削除しますか?"
description="この操作は取り消せません。削除すると、ユーザー情報は一覧から削除されます。"
confirmLabel="削除する"
cancelLabel="キャンセル"
variant="danger"
isLoading={isDeleting}
onConfirm={handleDelete}
onClose={closeDeleteModal}
>
{selectedUser && (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
<div className="font-medium text-slate-900">
{selectedUser.name}
</div>
<div className="mt-1 text-slate-500">
{selectedUser.email}
</div>
</div>
)}
</ConfirmModal>
</div>
</DashboardLayout>
);
}これで、行アクションメニューから削除確認モーダルを開けるようになります。
削除確認の流れ
今回の削除確認の流れは以下です。
RowActionMenuの「削除する」をクリック
selectedUserに対象ユーザーをセット
ConfirmModalを開く
「削除する」をクリック
削除処理を実行
一覧から対象ユーザーを削除
ConfirmModalを閉じる
削除対象は、selectedUser として管理しています。
const [selectedUser, setSelectedUser] = useState<User | null>(null);削除ボタンを押したときに、対象ユーザーをセットします。
const openDeleteModal = (user: User) => {
setSelectedUser(user);
setIsDeleteModalOpen(true);
};削除処理では、対象ユーザーを一覧から取り除いています。
setUsers((currentUsers) =>
currentUsers.filter((user) => user.id !== selectedUser.id)
);更新確認にも使える
ConfirmModal は削除専用ではありません。
variant="default" を使えば、更新確認や公開確認にも使えます。
<ConfirmModal
isOpen={isUpdateModalOpen}
title="変更内容を保存しますか?"
description="入力した内容で情報を更新します。"
confirmLabel="保存する"
cancelLabel="キャンセル"
variant="default"
isLoading={isSaving}
onConfirm={handleSave}
onClose={closeUpdateModal}
/>公開確認にも使えます。
<ConfirmModal
isOpen={isPublishModalOpen}
title="記事を公開しますか?"
description="公開すると、ユーザーがこの記事を閲覧できるようになります。"
confirmLabel="公開する"
variant="default"
onConfirm={handlePublish}
onClose={closePublishModal}
/>このように、削除だけでなく、確認が必要な操作全般に使えるようにしています。
実装のポイント
今回のポイントは、ConfirmModal を削除専用にしすぎず、確認操作全般に使えるようにしたことです。
削除や確認といった操作に対して、同じUIで確認を挟めます。
ConfirmModal では、タイトル、説明文、実行ボタン、キャンセルボタン、loading状態を標準で持たせています。
そのため、確認ダイアログとしての見た目や操作を画面ごとに書き直さなくて済みます。
まとめ
今回は、管理画面で使える ConfirmModal を作成しました。
以前作成した Modal は、自由な内容を表示するための汎用的な枠でした。
一方、今回の ConfirmModal は、削除確認や更新確認など、ユーザーに判断を求める操作に特化したコンポーネントです。
今回の実装では、以下に対応しました。
削除確認
更新確認
danger表示
loading状態
背景クリックで閉じる
Escキーで閉じる
loading中は閉じない
childrenで補足情報を表示する
RowActionMenuから開く
管理画面では、削除や更新などの操作が多くあります。
ConfirmModal を用意しておくと、危険な操作や確認が必要な操作を安全に扱いやすくなります。
削除後にトースト通知を表示したり、API通信と組み合わせたりすると、さらに実務に近い管理画面にできそうです。
#NextJS #React #コンポーネント #確認 #モーダル #ダイアログ #削除 #更新