How-To: Confirm + Toast Pattern for Destructive Actions
Every update or delete in the CPR admin panel and end-user app uses the same UX pattern:
- Update/Delete → opens a
ConfirmDialogmodal - Confirm → fires the request
- Result →
vue-sonnertoast (successorerror)
Creates do not get confirmation — they're additive and easy to undo.
See memory: admin-crud-ux-conventions.
The recipe
<script setup lang="ts">
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
import { toast } from 'vue-sonner'
import { route } from 'ziggy-js'
const props = defineProps<{ user: User }>()
const showDelete = ref(false)
const target = ref<User | null>(null)
function askDelete(user: User) {
target.value = user
showDelete.value = true
}
function confirmDelete() {
if (!target.value) return
router.delete(route('admin.users.destroy', target.value.id), {
preserveScroll: true,
onSuccess: () => toast.success('User deleted'),
onError: (errors) => toast.error(errors.message ?? 'Failed to delete'),
onFinish: () => {
showDelete.value = false
target.value = null
},
})
}
</script>
<template>
<button class="text-red-600" @click="askDelete(user)">Delete</button>
<ConfirmDialog
v-model="showDelete"
title="Delete this user?"
:message="`This will permanently remove ${target?.name}.`"
confirm-label="Delete"
variant="danger"
@confirm="confirmDelete"
/>
</template>Update with confirmation
For updates that have side effects (deactivate user, cancel reservation, transfer queue ticket), use the same pattern with router.put:
function confirmCancel() {
router.put(route('admin.reservations.cancel', reservation.value.id), {}, {
preserveScroll: true,
onSuccess: () => toast.success('Reservation cancelled'),
onError: () => toast.error('Failed to cancel'),
onFinish: () => { showCancel.value = false },
})
}Update redirects don't navigate
After update, the admin controller redirects to the edit page, not the index. Inertia treats that as a re-render of the current page, so preserveScroll keeps the scroll position. See memory: admin-crud-ux-conventions.
ConfirmDialog API
defineProps<{
modelValue: boolean // v-model
title: string
message?: string
confirmLabel?: string // default 'Confirm'
cancelLabel?: string // default 'Cancel'
variant?: 'primary' | 'danger' | 'warning'
loading?: boolean
}>()
defineEmits<{
'update:modelValue': [boolean]
confirm: []
cancel: []
}>()For a slow operation, bind :loading to a local ref so the confirm button shows a spinner:
const submitting = ref(false)
function confirmDelete() {
submitting.value = true
router.delete(route('admin.users.destroy', target.value!.id), {
onFinish: () => { submitting.value = false; showDelete.value = false },
// …
})
}Toast variants
import { toast } from 'vue-sonner'
toast.success('Saved')
toast.error('Network error')
toast.warning('Capacity nearly reached')
toast.info('Backup started')Don't toast on create success if the page redirects to a different route — Inertia flash messages already render via <Head> or a global flash banner.
When to skip confirmation
- Create — no confirmation, redirect to index with flash message
- Search/filter — no confirmation
- Toggle that's easy to reverse — small toggle (e.g.
is_active) can skip confirmation if it's a single click and reversible
When NOT to use toast
- Validation errors (422) → render inline next to the field
- Permission denied (403) → show full-page error (the user got somewhere they shouldn't be)
- Network errors during table refresh → render a row-level error state, not a toast
Checklist
- [ ] Update/Delete shows
ConfirmDialogbefore firing - [ ] Success toast on
onSuccess - [ ] Error toast on
onError(useerrors.messageif the backend sent one) - [ ] Dialog closes in
onFinish(fires for both success and error) - [ ]
preserveScroll: trueso the page doesn't jump - [ ] Create does not confirm
- [ ] Validation errors render inline, not as a toast
