Skip to content

useConfirmation

What this solves

Confirmation UX is easy to get wrong when it’s implemented ad hoc:

  • Multiple dialogs with inconsistent copy/labels
  • Async flows that continue even when the user cancels
  • State scattered across buttons, modals, and callbacks

useConfirmation centralizes the confirmation flow with a simple promise-based API: call confirm(...), await a boolean, and render one dialog bound to the hook state.


When to use it

Use useConfirmation for:

  • Destructive actions (delete, remove access, archive)
  • Risky actions (reset, overwrite, bulk operations)
  • Any flow where you want a single, consistent confirm dialog

When NOT to use it

Do not use useConfirmation when:

  • The decision is part of a larger multi-step flow (use a dedicated modal/wizard)
  • You need multiple simultaneous confirmations (this hook models a single active confirmation at a time)
  • You need to collect additional input beyond confirm/cancel (build a custom dialog)

Basic usage

Render the dialog once, and await confirm(...) from actions.

tsx
import { useConfirmation } from '@/shared/hooks'
import { ConfirmDialog } from '@/shared/ui/components/ConfirmDialog'

export function Example() {
  const { state, confirm, handleConfirm, handleCancel } = useConfirmation()

  const handleDelete = async () => {
    const ok = await confirm({
      title: 'Delete item?',
      description: 'This action cannot be undone.',
      confirmLabel: 'Delete',
      cancelLabel: 'Cancel',
      variant: 'destructive',
    })

    if (!ok) return

    // await api.item.delete()
  }

  return (
    <>
      <button type="button" onClick={handleDelete}>Delete</button>

      <ConfirmDialog
        open={state.isOpen}
        options={state.options}
        onConfirm={handleConfirm}
        onCancel={handleCancel}
      />
    </>
  )
}

Common patterns

Pattern 1 — Page-level confirmation (single source of truth)

Keep the confirmation hook at the page/container level and pass confirm to children that trigger risky actions.

tsx
import { useConfirmation } from '@/shared/hooks'
import { ConfirmDialog } from '@/shared/ui/components/ConfirmDialog'

function DeleteButton({ onRequestDelete }: { onRequestDelete: () => Promise<void> }) {
  return <button type="button" onClick={onRequestDelete}>Delete</button>
}

export function Page() {
  const { state, confirm, handleConfirm, handleCancel } = useConfirmation()

  const requestDelete = async () => {
    const ok = await confirm({ title: 'Delete?', variant: 'destructive' })
    if (!ok) return
    // await api.delete()
  }

  return (
    <>
      <DeleteButton onRequestDelete={requestDelete} />
      <ConfirmDialog open={state.isOpen} options={state.options} onConfirm={handleConfirm} onCancel={handleCancel} />
    </>
  )
}

Why this is recommended:

  • One dialog instance for the page
  • Children stay stateless and reusable

Pattern 2 — Compose with useAsyncAction for the destructive operation

Use confirmation to gate the action, then let useAsyncAction handle the operation state.

tsx
import { useAsyncAction, useConfirmation } from '@/shared/hooks'
import { ConfirmDialog } from '@/shared/ui/components/ConfirmDialog'

export function DeleteUser() {
  const { state, confirm, handleConfirm, handleCancel } = useConfirmation()
  const remove = useAsyncAction(async () => {
    // await api.user.delete()
    return true
  })

  const onClick = async () => {
    const ok = await confirm({ title: 'Delete user?', variant: 'destructive' })
    if (!ok) return
    await remove.run()
  }

  return (
    <>
      <button type="button" onClick={onClick} disabled={remove.isLoading}>
        {remove.isLoading ? 'Deleting…' : 'Delete'}
      </button>

      <ConfirmDialog open={state.isOpen} options={state.options} onConfirm={handleConfirm} onCancel={handleCancel} />
    </>
  )
}

Things to avoid

  • Rendering multiple ConfirmDialog instances for the same confirmation state
  • Calling confirm(...) without ensuring a dialog is rendered (the promise will never resolve)
  • Putting domain side effects inside the dialog component; keep them in the action handler that awaits the boolean


API reference (concise)

ts
export interface ConfirmOptions {
  title: string
  description?: string
  confirmLabel?: string
  cancelLabel?: string
  variant?: 'default' | 'destructive'
}

export interface ConfirmationState {
  isOpen: boolean
  options: ConfirmOptions | null
}

export interface UseConfirmationReturn {
  state: ConfirmationState
  confirm: (options: ConfirmOptions) => Promise<boolean>
  handleConfirm: () => void
  handleCancel: () => void
}

export function useConfirmation(): UseConfirmationReturn

Final notes

  • confirm(...) is promise-based; the promise resolves only when handleConfirm() or handleCancel() is called (typically from ConfirmDialog).
  • Keep confirmation copy short and explicit; prefer variant: 'destructive' for irreversible actions.