Back to Course

Hotwire Series · 5 of 6

Turbo morphing in Rails 8

Where morph mode came from, what it preserves that full replacement loses, when to morph instead of replace, and the gotchas around focus, scroll, animations, and Stimulus lifecycle that catch teams on first contact.

Where this rule comes from

Through Turbo 7, the way Turbo Drive (and Turbo Streams' replace action) updated the page was wholesale. The new HTML arrived, Turbo swapped out the old block, and whatever client-side state lived inside that block disappeared. Form fields lost focus. Scrollbars jumped. Stimulus controllers disconnected and reconnected. CSS animations restarted. The page worked, but it always felt like a page reload, even when it was not.

Turbo 8 (which shipped with Rails 8 in September 2024) added morph mode. The technique borrows from the Idiomorph library, written by the htmx team. Instead of replacing the old DOM with the new, the algorithm walks both trees in parallel and applies the minimum set of changes: this attribute, that text node, this added child. Elements that look the same are left alone. Elements that have changed get patched in place.

What survives morphing is everything that lives on a DOM element rather than its HTML representation. Form inputs keep their values and focus. Scrollbars stay where they were. Stimulus controllers remain connected. CSS transitions on attributes that did not change continue to play. The page feels alive across updates, which is the same property React reconciliation gives a React app, but achieved with server-rendered HTML and a few hundred lines of client JavaScript.

Mastodon adopted morphing across their main feed in late 2024. Basecamp uses it for their multi-column "scaffold" layout, where individual columns morph while the rest of the page sits still. The pattern is becoming the default for any Rails 8 page that updates often and is heavy on stateful UI.

The anti-pattern: full-page replacement on every update

A team builds a search page. The user types in a search box, the form posts to /search, the server returns a new page with results. With Turbo Drive's default replace behavior, three things happen on every keystroke:

  • The search input loses focus. Each keystroke effectively triggers a click-elsewhere followed by re-focusing the input, which most browsers handle but some assistive technologies announce as a focus change.
  • The result list flickers. The old results disappear, blank space briefly shows, the new results appear.
  • Any CSS animation on the result cards (a fade-in, a stagger) restarts from scratch every time.

The team's workarounds usually involve preserving focus manually in JavaScript, debouncing the search hard enough to mask the flicker, or accepting it. None of these are good. The page feels jittery because the underlying mechanic is "throw away the page, render a new one."

How morphing works

Morphing is enabled with a meta tag in the layout:

<%# app/views/layouts/application.html.erb %>
<head>
  <%# ...other head content %>
  <meta name="turbo-refresh-method" content="morph">
  <meta name="turbo-refresh-scroll" content="preserve">
</head>

With morph enabled, the page update flow changes. The server still returns full HTML. Turbo parses the new HTML, then walks the old DOM and the new DOM in parallel. For each pair of corresponding elements:

  • If they are identical, do nothing. The old element stays.
  • If attributes have changed, patch the attributes on the old element.
  • If text content has changed, update the text node in place.
  • If children differ, recurse into them with the same algorithm.
  • If a new element exists in the new HTML, insert it.
  • If an old element no longer exists, remove it.

The algorithm uses an id attribute as the matching key when present, falling back to position-in-parent when not. Elements with stable ids morph cleanly. Elements without ids morph by structural position, which usually works but degrades if siblings reorder.

For the search-page example, the search input element matches by id between the old and new HTML. Morphing applies zero changes to it. The input keeps focus, keeps its cursor position, keeps its current value. The result list reorders or replaces its children, but the surrounding chrome stays put.

The gotchas

Morphing is not free. Four classes of problem show up in real Rails 8 apps adopting it.

1. Stable ids are now load-bearing. Elements without ids get matched by position, so a reordered list (sort by date, then sort by name) confuses the morph algorithm. The fix is to put an id on every list item. Use dom_id(record) as the rule.

2. Stimulus controllers do not reconnect on morph. A controller that runs setup in connect() assuming the DOM is fresh will not run that setup on a morph update. If the morph changes a child element the controller cares about, you need to listen for the change explicitly. Stimulus 3.2 added data-turbo-permanent as an escape hatch and morph-aware lifecycle hooks; check your Stimulus version.

3. CSS animations triggered by class changes still play. If you add a .fade-in class via the new HTML, the morph applies the class and the animation runs. The effect is that lists where items toggle visibility get the animation each time, which is sometimes what you want and sometimes a flicker. The fix is to set a sentinel attribute (data-animated="true") after the first run, and gate the animation on the absence of that attribute.

4. Form state across morphs is per-element, not per-form. Inputs preserve their values on morph if they keep the same id. Wrap a form in a Turbo Frame and the inputs inside that frame retain their state when the frame morphs. But if the surrounding form element itself is replaced (because its outer id changed), all the inputs inside lose their values. The rule: keep form-element ids stable across renders.

When to morph, when to replace

Morph mode is opt-in per page (via the meta tag) or per Turbo Stream (via the turbo_stream.morph action introduced in Turbo 8). The decision rule:

  • Morph when continuity matters. The user is mid-action on the page: typing, scrolled to a specific spot, has form state. The update is incremental: a result list refines, a counter ticks up, a status toggles.
  • Replace when the change is conceptually a new page. Navigation between sections, switching from view-mode to edit-mode, anything where the user has crossed a real boundary and would not expect their previous state to survive.

A useful heuristic: if a full reload of the page would feel correct, replace is fine. If a full reload would feel like the page broke, morph is the right call.

The principle at play

Preserve UI continuity unless the change is conceptually a fresh page. The work the browser does between user interactions is what makes a web app feel like an app and not like a series of documents loading. Scroll position, focus, transient form state, in-flight animations: these are the small pieces that say "the same page is still here, with new content." Throwing them away on every server response makes Hotwire feel like a 2003 form post; preserving them makes it feel like Notion.

The senior judgment is recognising which updates are "same page, new content" and which are "different page." Morph the former. Replace the latter. The decision is rarely the same for every interaction on a single page, which is why morph mode is opt-in per page and per stream rather than a global toggle.

Practice exercise

  1. Open a Rails 8 app with a search page, a filter sidebar, or any interface where the same page updates frequently. Enable morphing via the meta tag.
  2. Verify which interactions feel better (the search input keeps focus, the scroll position holds) and which break (a Stimulus dropdown that re-runs its setup, a CSS animation that no longer triggers).
  3. For each break, ask: is the problem an unstable id, an order-dependent setup, or an animation that needs gating? Apply the right fix.
  4. Add a turbo-stream.morph action to one CRUD response in the app. Compare the resulting feel against the previous replace-based version.

Related lessons