Button
A button rendered in the native navigation bar. The web side registers a title; iOS draws a UIBarButtonItem; every tap is relayed back to the web side.
Outside of Hotwire Native nothing is registered and your own markup is rendered instead, so the same page still works in a regular browser.
Opening the page registers Tap me in the navigation bar; each tap is counted by the web page below it.
You copy it, you own it
There is nothing to install. Both files below are the complete component — paste them 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/BridgeButton.tsx in your app:
import { useEffect, useRef, type ReactNode } from 'react'
import { useBridgeComponent } from 'inertia-hotwire-native/react'
interface BridgeButtonProps {
/** Label shown on the native navigation-bar button. */
title: string
/** Which side of the navigation bar. Defaults to the trailing edge. */
side?: 'left' | 'right'
/** Called each time the native button is tapped. */
onTap?: () => void
/** Web fallback: rendered when there is no native adapter (regular browser). */
children?: ReactNode
}
/**
* Renders a native navigation-bar button inside Hotwire Native. In a regular
* browser (no native adapter) it renders `children` as a normal web control.
*/
export function BridgeButton({ title, side = 'right', onTap, children }: BridgeButtonProps) {
const { supported, send } = useBridgeComponent('button')
// Keep the tap handler in a ref so it can change without re-registering the
// native button on every render.
const onTapRef = useRef(onTap)
onTapRef.current = onTap
useEffect(() => {
if (!supported) return
// Native replies to "connect" each time the bar button is tapped.
const id = send('connect', { title, side }, () => onTapRef.current?.())
// Drop the old callback before re-registering, so a title/side change does
// not leave a second one behind and report every tap twice.
return () => window.HotwireNative?.web?.removeCallback(id)
}, [supported, title, side, send])
if (supported) return null
return <>{children}</>
}Then use it as a component. It renders nothing when the native button is showing, and renders its children as the web fallback when it is not:
import { BridgeButton } from '@/bridge/BridgeButton'
function Article({ onSave }) {
return (
<>
<BridgeButton title="Save" onTap={onSave}>
<button type="button" onClick={onSave}>Save</button>
</BridgeButton>
{/* … */}
</>
)
}| Prop | Type | Default | Purpose |
|---|---|---|---|
title | string | — | Label on the native button |
side | 'left' | 'right' | 'right' | Which end of the navigation bar |
onTap | () => void | — | Called on every tap |
children | ReactNode | — | Web fallback, rendered only in a browser |
The file is TypeScript. Vite compiles .tsx with no configuration change, even in a project that is otherwise plain .jsx — types are stripped by esbuild. Without typescript and a tsconfig.json they are not checked, only removed.
iOS side
Add this file to your Xcode project:
import Foundation
import HotwireNative
import UIKit
/// Native counterpart of the `button` bridge component. Draws a navigation-bar
/// button from the web side's `connect` message and relays taps back by replying
/// to that same message.
///
/// Register once with `Hotwire.registerBridgeComponents([ButtonComponent.self])`.
final class ButtonComponent: BridgeComponent {
override nonisolated class var name: String { "button" }
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else {
return
}
switch event {
case .connect:
handleConnectEvent(message: message)
}
}
// MARK: Private
private var viewController: UIViewController? {
delegate?.destination as? UIViewController
}
private func handleConnectEvent(message: Message) {
guard let data: MessageData = message.data() else { return }
let action = UIAction { [unowned self] _ in
// Reply to "connect" — the web side treats this as the tap signal.
reply(to: Event.connect.rawValue)
}
let item = UIBarButtonItem(title: data.title, primaryAction: action)
switch data.side {
case "left":
viewController?.navigationItem.leftBarButtonItem = item
default:
viewController?.navigationItem.rightBarButtonItem = item
}
}
}
// MARK: Events
private extension ButtonComponent {
enum Event: String {
case connect
}
}
// MARK: Message data
private extension ButtonComponent {
struct MessageData: Decodable {
let title: String
let side: String?
}
}Then register it at launch, in AppDelegate:
Hotwire.registerBridgeComponents([
ButtonComponent.self,
// … your other components
])Until it is registered, supported stays false on the web side and only the fallback is rendered.
The contract
Component name: button.
connect — web → native
Registers or re-registers the bar button. Sent on connect and whenever the title or side changes.
{
"title": "Save", // string, required — button label
"side": "right" // "left" | "right", optional, default "right"
}connect reply — native → web
Native replies to the same connect message every time the button is tapped. There is no separate tap event — the reply is the tap signal, and it arrives once per tap rather than once per registration.
Because a reply can arrive many times, re-registering without dropping the previous callback makes each tap fire twice. The registry component handles this; see Callback lifetime if you write your own.
Android
Not covered here yet. The registry ships a ButtonComponent.kt, but it has not been verified against a pinned Android SDK version — treat it as unversioned until it has.