Payload CMS ships a ConfirmationModal component and a useModal() hook in @payloadcms/ui that together give you an in-app confirm dialog matching the rest of the admin UI — no window.confirm(), no custom modal state machine. Wire a button to openModal(slug), declare a <ConfirmationModal modalSlug={slug}> next to it, and Payload handles opening, closing, and the confirm-button's loading spinner for you.
I ran into this while building a multi-step approval workflow for a client's Payload 3 project — a custom admin view where reviewers approve, reject, request changes on, withdraw, or cancel a submission. Every one of those actions is destructive-ish or hard to undo, so each needed a "are you sure?" step with an optional comment field. window.confirm() was never going to look right sitting inside a styled admin panel, and building a custom modal from scratch for five separate actions felt like overkill. Payload already ships the primitive — it's just not obviously documented as "the confirm dialog."
The pattern in three steps
1. Open the modal with a slug, not the action
Your button doesn't run the action directly. It opens a modal identified by a unique string slug.
openModal comes from Payload's global modal controller — you don't need to track open/closed state yourself. Slugs are just strings, so anything unique works, but scope them to the entity the action applies to (more on why in the next section).
2. Declare a ConfirmationModal for that slug
Somewhere in the same component tree (it doesn't have to be right next to the button — it just needs the same slug), render a ConfirmationModal. It's always mounted; it only becomes visible when its modalSlug matches an open call.
tsx
{ , } ;
body accepts any React node, so it's not limited to plain confirmation text — a TextareaInput for an optional reason, a warning banner, a summary of what's about to change, whatever the action needs. confirmLabel and confirmingLabel give you the idle and in-flight button text for free; you don't need your own isSubmitting state just to swap "Reject" for "Rejecting…".
3. Handle the confirm — call your API, then toast and refresh
onConfirm is where the actual work happens. Payload doesn't assume anything about what "confirm" means beyond running your callback and managing the modal's loading state while it's in flight.
Two things worth calling out here. First, expectedUpdatedAt — passing the record's last-known updatedAt to your API lets the server reject the action with a 409 if the record changed since the page loaded, which matters a lot when multiple reviewers can act on the same request. Second, router.refresh() after a successful action reloads server-rendered state so the view reflects the new status immediately, rather than trusting client-side optimistic state that could drift from what actually happened on the server.
Why slug modals per-entity, not per-action-type
It's tempting to key modals by action name alone — one "reject" slug, reused everywhere. That breaks the moment the same action type appears more than once on a page: a list view with a reject button per row, or a dashboard rendering multiple pending requests. Every row would open the same modal instance, and confirming would apply to whichever row happened to set the shared state last — not necessarily the one the user clicked.
Scoping the slug to the record it acts on, like approval-request-${requestId}-reject, avoids that entirely. Each row gets its own modal instance with its own identity, so there's no shared state to collide.
Default to per-entity slugs even on a detail page where you think you'll never have duplicates. Pages grow, and a reused-string slug bug only shows up once someone adds a second instance of the component months later.
Minimal copy-paste template
Stripped down to the essentials, here's the whole pattern in one place:
Drop this into any custom Payload admin component — a custom list column, a document view, a dashboard widget — and swap myApiCall for whatever your action actually does.
FAQ
Does ConfirmationModal block the page like window.confirm() does?
No — it's a React modal overlay, not a blocking browser dialog. The rest of the page stays interactive underneath it (though visually obscured by the modal backdrop), and your app's state and event loop keep running normally.
Can I have multiple ConfirmationModals open at once?
Only one modal is visible at a time by design — opening a new slug replaces whichever modal is currently showing. This is a UI constraint, not a technical limit on how many ConfirmationModal components you can declare.
Do I need to close the modal manually after onConfirm runs?
No, for the success path. Payload closes the modal automatically once your onConfirm promise resolves. If it rejects, the modal stays open with confirmLabel restored, so the user can retry — you handle the error (e.g., a toast) without needing to also manage the modal's open state.
Can I use ConfirmationModal outside of Payload's admin UI, in my own Next.js app?@payloadcms/ui is designed for the Payload admin panel and pulls in its styling/theme context, so it's meant for custom admin components, not your site's public-facing frontend. For a public app you'd want a general-purpose modal/dialog library instead.
What happens if the user's session expires or the request fails with something other than a normal error?
That's on your onConfirm implementation — ConfirmationModal just awaits whatever promise you give it. Wrap your API call in a try/catch (as shown above) and surface failures with toast.error, and consider checking for auth-specific error shapes if your API distinguishes them.
Wrapping up
ConfirmationModal plus useModal() gets you a properly styled, admin-matching confirm dialog with async support and a built-in loading state, without writing your own modal component or state machine. The only real decision to get right up front is slugging modals per-entity so they don't collide once the same action shows up more than once on a page — everything else is filling in heading, body, and onConfirm for whatever action you're gating.
Let me know in the comments if you have questions, and subscribe for more practical development guides.