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:
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:
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>
}| Option | Type | Default | Purpose |
|---|---|---|---|
title | string | — | Headline of the alert |
description | string | — | Body text under the title |
destructive | boolean | false | Draw the confirming action as destructive |
confirm | string | 'OK' | Label of the confirming action |
dismiss | string | '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:
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:
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.
{
"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:
// 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:
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.