rule 14 — platform defaults

Never alert(), confirm() or prompt()

These three draw the browser's own dialog: unthemed, blocking, and outside React's tree. Show feedback with a Toast under a ToastProvider, and ask a question with `await confirm(...)` from useConfirm() under a ConfirmProvider.

errorlintno-browser-dialogs

platform defaults

The platform will do it for you. That is not the same as your app doing it.

what this catches

An agent asked to confirm a delete or to tell the user something worked writes `confirm(...)` or `alert(...)`, because a global needs no import, no provider, no state and no second render path — it is the only feedback mechanism guaranteed to exist in every JavaScript runtime, so it is the one the shortest path finds. It works on the first click, on the machine that wrote it, which is exactly why nothing in review objects.

why

The dialog is not yours. `alert()` renders browser chrome: your theme does not reach it, your fonts do not reach it, and neither does your dark mode. It is the one surface in the product the design system cannot touch, which makes it the one surface that announces the app was assembled rather than designed. Toast is a component, so it inherits everything — the quebi surface, the status ramps, the radii, the motion — and so is AlertDialog.

It blocks the main thread, and that is a behavioural difference, not an aesthetic one. While an `alert()` is open nothing renders, no timer fires, no fetch settles and no route transition completes; on iOS Safari the user can tick a box that suppresses every later dialog on the origin, at which point `confirm()` returns false forever and the code silently stops asking. A Toast is transient and non-blocking, and an AlertDialog is a focus-trapped dialog inside the tree, where the rest of the app keeps running behind it.

It cannot be tested and it cannot be prerendered. `alert` and `confirm` are not defined on the server, so the same call that works in the browser throws during a prerender; in happy-dom they are stubs that return undefined, so a test of the flow behind a `confirm()` either hangs on a decision nobody makes or takes a branch the user never would. A Toast is a rendered node with an aria-live region — `findByRole('status')` — and a confirmation is a dialog with a button you can press and a promise you can await.

Both replacements now name themselves, which is why this rule fails rather than warns. `toast` is not exported; the queue is reached through `useToast()` under a `<ToastProvider>`. `confirm()` is reached through `useConfirm()` under a `<ConfirmProvider>` — and because it returns `Promise<boolean>`, the handler that asked the question keeps its shape and gains one `await`, rather than being split in two around a Modal's button. Each replacement is one import plus a provider mounted once at the root; the declarative shape, `<AlertDialog>` with `isOpen` and `onConfirm`, is still there for the cases that want it. Nothing left here is a refactor of the call site, so nothing left here is a judgement call.

wrong / right

Telling the user it worked

don't

async function onSave() {
  await save(values)
  alert("Saved")
}

do

import { useToast } from "@/components/toast"

const toast = useToast()

async function onSave() {
  await save(values)
  toast.success("Saved")
}

useToast() throws without a <ToastProvider> above it, so this is two edits, not one: mount the provider once at the root of the app and every later call is the one-liner it looks like.

Asking before a destructive action

don't

function onDelete() {
  if (!confirm("Delete this project?")) return
  deleteProject(id)
}

do

import { useConfirm } from "@/components/alert-dialog"

const confirm = useConfirm()

async function onDelete() {
  if (!(await confirm("Delete this project?"))) return
  deleteProject(id)
}

The diff is the `await` and the import. That is the whole reason `useConfirm()` exists: `confirm()` answers the line below it, and so does a promise — where a Modal does not, which is why replacing one with the other used to mean splitting the handler in two. `<ConfirmProvider>` goes at the root next to `<ToastProvider>`; a dismissal (Escape, or Cancel) resolves `false`.

When the dialog is a piece of the page, not a question in a handler

don't

<Button intent="danger" onPress={() => {
  if (confirm("Revoke this API key?")) revoke(key)
}}>
  Revoke
</Button>

do

<AlertDialog
  isOpen={isOpen}
  onOpenChange={setIsOpen}
  onConfirm={() => revoke(key)}
  title="Revoke this API key?"
  description="Anything still using it will start failing immediately."
  confirmLabel="Revoke"
  intent="danger"
/>

The declarative half of the same component, for a confirmation whose open state something else already owns — a route, a selection, a menu item. `role="alertdialog"` comes with it: focus-trapped, not dismissable by clicking the scrim, and announced as a dialog demanding an answer.

how to check this

Add one of these to your project and the rule holds without anyone having to remember it — including the agent writing half the JSX. The exceptions below are already applied, so a documented carve-out will not be reported.

What it will and will not catch: Biome prints its own text for this one — 'Unexpected alert' — because noAlert has no message option to carry the replacement, unlike the element ban. The message above is what this page and api/rules/no-browser-dialogs.json say, and it is the part worth reading; the lint run only tells you where. The check reads the call, so an indirection (`const ask = window.confirm`) is invisible to it, as is anything reached through a variable — the same blind spot every syntactic check here has. It also cannot see the case the rule most wants to catch in reverse: a Toast fired where a confirmation was needed is a judgement call, not a pattern. And `prompt()` is the one global whose replacement is not a single call: a question that collects a value is a Modal with an Input and a submit, because the answer needs validating.

Biome — suspicious/noAlert

biome

A built-in Biome rule, so there is nothing to install and no pattern to maintain. One message per element, and the documented exceptions are ordinary `overrides`.

// biome.jsonc
{
  "linter": {
    "rules": {
      "suspicious": {
        "noAlert": {
          "level": "error"
        }
      }
    }
  }
}

ripgrep — no setup at all

ripgrep

Finds candidates for review in any repo, linter or not. Coarser than the Biome check: it reads lines, not syntax, so expect false positives and treat a clean run as weaker evidence than a clean lint run.

# no-browser-dialogs — candidates for review
rg -n -g '*.{js,jsx,ts,tsx}' \
  "\\b(?:window\\.)?(?:alert|confirm|prompt)\\("

Enforcing more than this one rule? Take the whole config instead of collecting snippets.

exceptions

Carve-outs are part of the rule, not a way around it. Each one is already an ignore glob in the checks above, so the cases listed here need no disable comment — and a case that is not listed is one to argue for, not to silence.

None
Every case this rule covers has a replacement that is an import, so there is nothing here that a documented carve-out would be honest about. A case it gets wrong is one to argue for on the record, not to silence with a suppression comment.

scope and enforcement

Applies to
  • app/**/*.{ts,tsx,js,jsx}
  • src/**/*.{ts,tsx,js,jsx}
Enforced by
lint