Skip to content

Alert

A native confirmation dialog put in front of an action. The web side asks for the alert and hears back only if it was confirmed; iOS presents a UIAlertController and Android an AlertDialog.

Outside of Hotwire Native nothing is registered and the hook falls back to window.confirm, so the same page still works in a regular browser.

Confirming reports back and the page acts on it. Dismissing reports nothing at all — the page is left exactly as it was.

You copy it, you own it

There is nothing to install. The files below are the complete component — paste the web one plus whichever platforms you ship into your app, and change them however you like. They are shown straight from the hotwire-bridge-components registry, so what you see here is what the registry holds.

Web side

Save this as bridge/useBridgeAlert.tsx in your app:

tsx
import { useCallback, useRef } from 'react'
import { useBridgeComponent } from 'inertia-hotwire-native/react'

export interface BridgeAlertOptions {
  /** Headline of the alert. */
  title: string
  /** Body text under the title. */
  description?: string
  /** Draw the confirming action in the platform's destructive style. */
  destructive?: boolean
  /** Label of the confirming action. */
  confirm?: string
  /** Label of the dismissing action. */
  dismiss?: string
}

export interface BridgeAlert {
  /** Whether the connected native app draws the alert. */
  supported: boolean
  /** Present the alert; `onConfirm` runs only if it was confirmed. */
  show(options: BridgeAlertOptions, onConfirm?: () => void): void
}

/**
 * Presents a native confirmation dialog inside Hotwire Native, and falls back to
 * `window.confirm` in a regular browser — so a caller never has to branch on
 * `supported`. That is returned for pages that would rather render their own
 * dialog than use the browser one.
 */
export function useBridgeAlert(): BridgeAlert {
  const { supported, send } = useBridgeComponent('alert')

  // A dismissed alert is answered with silence, so its callback is never
  // invoked and would sit in the bridge's map until the page unmounts. Dropping
  // the previous one before each show keeps at most one outstanding.
  const pendingId = useRef<string | null>(null)

  const show = useCallback(
    (options: BridgeAlertOptions, onConfirm?: () => void) => {
      const {
        title,
        description,
        destructive = false,
        confirm = 'OK',
        dismiss = 'Cancel',
      } = options

      if (!supported) {
        if (window.confirm([title, description].filter(Boolean).join('\n\n'))) onConfirm?.()
        return
      }

      if (pendingId.current) window.HotwireNative?.web?.removeCallback(pendingId.current)

      pendingId.current = send('show', { title, description, destructive, confirm, dismiss }, () => {
        pendingId.current = null
        onConfirm?.()
      })
    },
    [supported, send]
  )

  return { supported, show }
}

Then call show from wherever the action starts. It runs onConfirm only if the alert was confirmed:

jsx
import { useBridgeAlert } from '@/bridge/useBridgeAlert'

function DeleteButton({ file }) {
  const { show } = useBridgeAlert()

  const destroy = () => {
    show(
      {
        title: 'Delete this file?',
        description: 'This cannot be undone.',
        destructive: true,
        confirm: 'Delete',
      },
      () => router.delete(`/files/${file.id}`)
    )
  }

  return <button type="button" onClick={destroy}>Delete</button>
}
OptionTypeDefaultPurpose
titlestringHeadline of the alert
descriptionstringBody text under the title
destructivebooleanfalseDraw the confirming action as destructive
confirmstring'OK'Label of the confirming action
dismissstring'Cancel'Label of the dismissing action

useBridgeAlert() also returns supported, for a page that would rather render its own dialog than let the browser fallback handle it.

A hook, not a component

Button draws native UI the moment it mounts, so it is a component whose children are the web fallback. This one draws nothing until it is called — there is no markup for a component to own, so it ships as a hook.

iOS side

Add this file to your Xcode project:

swift
import Foundation
import HotwireNative
import UIKit

/// Native counterpart of the `alert` bridge component. Presents a
/// `UIAlertController` from the web side's `show` message and replies to that
/// message only when the confirming action is tapped.
///
/// Register once with `Hotwire.registerBridgeComponents([AlertComponent.self])`.
///
/// Follows joemasilotti/bridge-components (MIT).
final class AlertComponent: BridgeComponent {
    override nonisolated class var name: String { "alert" }

    override func onReceive(message: Message) {
        guard let event = Event(rawValue: message.event) else {
            return
        }

        switch event {
        case .show:
            handleShowEvent(message: message)
        }
    }

    // MARK: Private

    private var viewController: UIViewController? {
        delegate?.destination as? UIViewController
    }

    private func handleShowEvent(message: Message) {
        guard let data: MessageData = message.data() else { return }

        let alert = UIAlertController(
            title: data.title,
            message: data.description,
            preferredStyle: .alert
        )

        // Only the confirming action answers. A dismissal is silence, per the
        // contract — the web side reads "no reply" as "not confirmed".
        let confirmAction = UIAlertAction(
            title: data.confirmTitle,
            style: data.confirmActionStyle
        ) { [weak self] _ in
            self?.reply(to: message.event)
        }
        alert.addAction(confirmAction)
        alert.preferredAction = confirmAction

        alert.addAction(UIAlertAction(title: data.dismissTitle, style: .cancel))

        viewController?.present(alert, animated: true)
    }
}

// MARK: Events

private extension AlertComponent {
    enum Event: String {
        case show
    }
}

// MARK: Message data

private extension AlertComponent {
    struct MessageData: Decodable {
        let title: String
        let description: String?
        let destructive: Bool?
        let confirm: String?
        let dismiss: String?

        // The contract makes every field but `title` optional, with the default
        // supplied here rather than on the web side.
        var confirmTitle: String { confirm ?? "OK" }
        var dismissTitle: String { dismiss ?? "Cancel" }

        var confirmActionStyle: UIAlertAction.Style {
            destructive == true ? .destructive : .default
        }
    }
}

Then register it at launch, in AppDelegate:

swift
Hotwire.registerBridgeComponents([
    AlertComponent.self,
    // … your other components
])

Until it is registered, supported stays false on the web side and show falls back to window.confirm.

The contract

Component name: alert.

show — web → native

Presents the alert. Sent once per confirmation, not on connect.

jsonc
{
  "title": "Are you sure?",                // string, required — headline
  "description": "This cannot be undone.", // string, optional — body text
  "destructive": true,                     // bool, optional, default false
  "confirm": "Delete",                     // string, optional, default "OK"
  "dismiss": "Cancel"                      // string, optional, default "Cancel"
}

show reply — native → web

Native replies to the show message only when the confirming action is tapped. Dismissing the alert sends nothing at all. The reply carries no data — it is the confirmation signal itself.

A dismissal is silence

Because nothing comes back from a cancelled alert, its callback is never invoked and stays in the bridge's map until the page unmounts. The registry hook drops the previous callback before each show, so at most one is ever outstanding. If you write your own web side, do the same — see Callback lifetime.

Android

The Kotlin half presents an AlertDialog. Add this file to your project:

kt
// Replace with your app's package.
package com.example.bridge

import android.util.Log
import android.util.TypedValue
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeDelegate
import dev.hotwire.core.bridge.Message
import dev.hotwire.navigation.destinations.HotwireDestination
import kotlinx.serialization.Serializable

/**
 * Native counterpart of the `alert` bridge component. Presents an
 * `AlertDialog` from the web side's `show` message and replies to that message
 * only when the confirming action is tapped.
 *
 * Register once with
 * `Hotwire.registerBridgeComponents(BridgeComponentFactory("alert", ::AlertComponent))`.
 *
 * The contract's `destructive` is honoured as far as the platform allows:
 * Android has no destructive button style, so the confirming button is tinted
 * with the theme's error colour instead.
 */
class AlertComponent(
    name: String,
    private val delegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, delegate) {

    private val fragment: Fragment
        get() = delegate.destination.fragment

    override fun onReceive(message: Message) {
        when (message.event) {
            "show" -> handleShowEvent(message)
            else -> Log.w("AlertComponent", "Unknown event for message: $message")
        }
    }

    private fun handleShowEvent(message: Message) {
        val data = message.data<MessageData>() ?: return
        val context = fragment.context ?: return

        // Only the confirming button answers. Dismissing — the dismiss button,
        // a tap outside, or the back press — is silence, per the contract.
        val dialog = AlertDialog.Builder(context)
            .setTitle(data.title)
            .setMessage(data.description)
            .setPositiveButton(data.confirm) { _, _ -> replyTo("show") }
            .setNegativeButton(data.dismiss) { dialog, _ -> dialog.dismiss() }
            .create()

        dialog.show()

        // Buttons only exist once the dialog is shown, so the tint comes after.
        if (data.destructive) {
            dialog.getButton(AlertDialog.BUTTON_POSITIVE)
                .setTextColor(context.errorColor())
        }
    }

    private fun android.content.Context.errorColor(): Int {
        val value = TypedValue()
        theme.resolveAttribute(com.google.android.material.R.attr.colorError, value, true)
        // A theme may hand back either a resolved colour or a reference to one.
        return if (value.resourceId != 0) ContextCompat.getColor(this, value.resourceId) else value.data
    }

    @Serializable
    data class MessageData(
        val title: String,
        val description: String? = null,
        val destructive: Boolean = false,
        val confirm: String = "OK",
        val dismiss: String = "Cancel"
    )
}

Then register it at launch, in your Application:

kotlin
Hotwire.registerBridgeComponents(
    BridgeComponentFactory("alert", ::AlertComponent),
    // … your other components
)

The same page on Android: dismissing with Keep leaves the page alone, confirming with Delete updates it.

No destructive style

Android has no destructive button style, so destructive tints the positive button with the theme's colorError instead. It reads as a warning rather than as the platform-standard destructive action iOS gives you.