Turbo Morphing and Hotwire Native in a Real Client App

Our default stack answers most frontend questions with "server-rendered HTML and Tailwind." That works right up until a client asks for two things in the same sprint: a dashboard that updates itself while people are looking at it, and an app in the App Store. Both are solved problems in Hotwire - morphing page refreshes for the first, Hotwire Native for the second - but neither is solved by reading the announcement post. Here's what we learned wiring both into a live client app.

Morphing is two meta tags and a change in how you think

Turbo's morphing refreshes are, mechanically, trivial to enable. Two tags in your layout:

<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">

turbo-refresh-method accepts morph or replace (the default). With morph, a page refresh no longer swaps the whole <body>. Turbo diffs the new document against the live one via idiomorph and touches only the nodes that actually changed. turbo-refresh-scroll accepts preserve or reset (the default); without preserve, you'll morph the DOM beautifully and then yank the user to the top of the page, which is worse than not morphing at all.

The conceptual shift is bigger than the diff. Before morphing, a "live" region meant a Turbo Stream: a controller or model deciding exactly which partial to replace, targeted by DOM ID. With morphing, the server's job collapses back to render the current state of this page correctly. Turbo works out the delta. A dashboard that took four turbo_stream.replace calls across three partials became one broadcast that says "refresh," and the diffing is someone else's problem.

On the Rails side that's a line in the model and a line in the view:

class Project < ApplicationRecord
  has_many :tasks
  broadcasts_refreshes
end
<%= turbo_stream_from @project %>

broadcasts_refreshes sends a <turbo-stream action="refresh"> to subscribers, debounced by half a second, so a bulk update of forty tasks doesn't fire forty refreshes. Every subscribed browser re-requests the page it's on and morphs the result. Two people editing the same project see each other's changes without either of us writing a targeted stream. That's the pitch, and it holds.

Then it breaks, and it breaks in the interesting places

Morphing is safe for text and dumb markup. It is not safe for anything holding state the server doesn't know about - which, in a real app, is most of the things users care about.

The failures we hit, in the order they hurt:

Rich text editors. A <trix-editor> is a DOM node with a live JavaScript instance behind it. Morph the node, and the instance is orphaned. The fix is data-turbo-permanent on the wrapper, which tells Turbo to leave that subtree alone entirely. The cost: it's now permanently stale, so after a successful form submission you need a Stimulus controller listening for turbo:submit-end to clear it by hand. You've traded automatic updates for manual lifecycle management in exactly the place you didn't want to be doing that.

Collapsed sections snapping open. <details> carries its state in the open attribute. The server renders it closed; morphing dutifully reconciles it back to closed while the user is reading it. This one has a precise fix - cancel the attribute change rather than exempting the element:

document.addEventListener("turbo:before-morph-attribute", (event) => {
  if (event.detail.attributeName === "open") event.preventDefault()
})

event.detail also carries a mutationType of "update" or "remove" if you need to narrow further, and there's a matching turbo:before-morph-element for skipping a node outright. Reach for the attribute-level event first; data-turbo-permanent is a blunt instrument and it's easy to freeze more than you meant to.

Open menus and popovers. Same class of problem, same data-turbo-permanent answer - the popover case is the one the handbook itself reaches for.

The rule we settled on: morphing goes on globally, and every stateful component gets an explicit decision recorded next to it. Not "does this look fine in dev" - dev has one user and no broadcasts. The realistic test is two browsers, one editing, one with a menu open and a half-filled form.

A note for table-style components. Content that arrived after the initial page load - a paginated table body, a lazily loaded frame - isn't in the server's fresh render of the page, so a refresh would drop you back to page one. Mark the frame:

<%= turbo_frame_tag :tasks, src: tasks_path, refresh: "morph" do %>
  <%= render @tasks %>
<% end %>

Frames flagged refresh="morph" don't get their contents removed on a page refresh; Turbo reloads the frame from its own src and renders the response with morphing, which keeps paginated content in sync instead of resetting it.

Hotwire Native: the shell is small, the seams are the work

The native side inverts the effort curve. Standing up an iOS shell around an existing Rails app is genuinely an afternoon: a navigation stack, a start URL, and your app is on a simulator. Nearly all of the views come across untouched because they're just your existing HTML.

The real artifact is the path configuration - a JSON file your Rails app serves that tells the native shell how to behave:

{
  "settings": {},
  "rules": [
	{ "patterns": ["/new$", "/edit$"], "properties": { "context": "modal" } }
  ]
}

rules matches URL regexes to properties; context: "modal" makes those screens present modally instead of pushing onto the stack. Because the app fetches this from your server, you can change navigation behavior for shipped builds without going through review - the single most valuable property of the whole setup. Bundle a copy locally for cold start, serve the canonical one remotely, and version the filename per platform (/configurations/ios_v1.json) so an old build never chokes on new rules. settings doubles as a remote feature-flag channel.

Inside the web views, hotwire_native_app? (from turbo-rails, registered as a helper so it reads the same in a controller or a view) is how you stop shipping the website to the app. It's a User-Agent check - the native clients append Hotwire Native to the UA string, and the helper matches legacy Turbo Native clients too:

<% unless hotwire_native_app? %>
  <%= render "shared/navbar" %>
  <%= render "shared/footer" %>
<% end %>

The navbar is native chrome now; rendering the web one is a duplicated header and an instantly obvious tell.

When you need real native behavior, bridge components are the escape hatch. They're Stimulus controllers extending BridgeComponent, with a static component name that must match the native registration:

import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "form"
  static targets = ["submit"]

  submitTargetConnected(target) {
	const submitTitle = new BridgeElement(target).title

	this.send("connect", { submitTitle }, () => {
	  target.click()
	})
  }
}

The web side sends a message as the submit target connects; the native side draws a real UIKit submit button in the nav bar and replies; the callback clicks the hidden web button. The form stays server-rendered - only the control is native. Registration happens on the native side - Hotwire.registerBridgeComponents([FormComponent.self, MenuComponent.self]) in AppDelegate on iOS, a BridgeComponentFactory per component on Android - and this.enabled tells the web side whether the running native app actually supports the component, so the same partial degrades cleanly in a browser.

What we'd tell the next team

Turn morphing on early, before there's much stateful UI to audit; retrofitting it across a mature app is where the day disappears. Treat data-turbo-permanent as a last resort behind turbo:before-morph-attribute. On native, spend your time on the path configuration and the hotwire_native_app? conditionals - that's where the app stops feeling like a website in a box - and write a bridge component only when a native control is the actual requirement.

Further reading

Looking for a fractional Rails engineer or CTO?

I take on a limited number of part-time clients.

Get in touch