Turbo 8: Morphing & Live Refresh
The push half of Turbo, grounded in Fizzy's Kanban board: how broadcasts_refreshes + morph turn "render the page again and diff it" into live multiplayer with no real-time code — and how a handful of declarations let a live refresh coexist with open menus, lazy frames, optimistic drags, and unsaved drafts. Net-new territory Campfire structurally lacks; the mechanics companion to P5 and the drag-and-drop capstone.
You're building a Kanban board. Four people have it open. One of them drags a card from "Triage" to "In Progress," another renames a column, a third closes a card. Every one of those changes has to land on the other three screens within a heartbeat — and it has to land without throwing away the menu someone has open, the textarea someone is mid-sentence in, or the card that's mid-flight in a CSS transition. That's the problem this tutorial answers: how do you keep four screens in sync without writing a single line of "when X happens, find node Y and patch it" code — and without that sync stepping on the transient state living in each browser?
[[F4a: Turbo — Frames & Streams]] taught the request-driven half of Turbo: a form submits, the server replies with a stream that appends or replaces a node you addressed by dom_id. That's the pull model — the change is the answer to a request you made. This tutorial is the push half, and it's almost entirely net-new territory: Campfire has no morph-refresh at all, so for once Fizzy is the sole worked example, not the second witness.
Let's be honest about the version you'd vibe-code first.
The naive version: a broadcast for every kind of change
You already know from F4a that a model can push HTML over a WebSocket. So the instinct is to do exactly that, by hand, for every change a card can undergo:
class Card < ApplicationRecord
after_create_commit -> { broadcast_prepend_to board, :cards, target: [board, column, :cards] }
after_update_commit -> { broadcast_replace_to board, :cards, target: self }
after_destroy_commit -> { broadcast_remove_to board, :cards }
end
class Column < ApplicationRecord
after_update_commit -> { broadcast_replace_to board, target: self } # renamed? recolored?
# ...and the column needs to re-broadcast every card inside it when its name changes,
# because each card shows its column's name. So:
after_update_commit -> { cards.each { |c| c.broadcast_replace_to board, target: c } }
end
Three things are already wrong, and none of them will raise. First, you're hand-authoring a different broadcast verb for every kind of change — append on create, replace on update, remove on destroy, times every model on the board (card, column, closure, assignment, tag, comment). Second, the fan-out is yours to maintain: when a column's name changes, every card that displays that name is now stale, so you write the loop that re-broadcasts all of them — and the day you add a board-level field that cards display, you have to remember to add another loop. Third — the one that actually bites in production — a replace is a destructive swap. It rips out the old node and drops in a new one. If the recipient had that card's "move to…" menu open, or was halfway through editing its title, the swap throws all of that away. So you reach for client-side bookkeeping: remember which menu was open, remember the cursor position, restore it after the swap. You're now maintaining a reconciliation layer by hand, in the browser, in a language the reader of this series doesn't even want to be writing.
The naive shape is a matrix: every model, times every verb, times a hand-written fan-out, times a hand-written "restore what the swap destroyed."
The mechanics, from first principles
Turbo 8 (the version 37signals shipped Fizzy on) replaces that entire matrix with one idea: stop telling the client what changed; just tell it that something changed, and let it diff.
The push payload isn't a verb and a fragment anymore. It's literally the word refresh. When a client receives a refresh signal on a stream it's subscribed to, it re-requests the current page from the server, gets back the full, fresh HTML, and then — instead of swapping the whole <body> — it morphs: it walks the old DOM and the new DOM side by side and changes only the nodes that actually differ. A card that didn't move doesn't get touched. A column whose name is identical doesn't get touched. The node you're hovering, the menu you have open, the textarea you're typing in — untouched, because they're the same in both trees.
"Re-requesting the whole page on every change — isn't that wildly wasteful compared to pushing one tiny fragment?" This is the trade DHH made on purpose, and it's the same trade as F4a's "ship HTML, not JSON," taken one level up. You spend one extra GET to delete the entire matrix above: no per-verb broadcasts, no hand-written fan-out, no client-side restore layer. The server already knows how to render the board — it has the templates — so the "diff engine" is just render it again and compare. The byte cost is real; the maintenance cost it deletes is a whole subsystem. We don't unpack the full worldview here — that's [[see P5: One Renderer, HTML Over the Wire]]'s job. For F4b, hold the mechanic: the wire carries the word
refresh; the client re-renders and morphs the diff.
Morphing is the load-bearing word. A replace is destructive: out with the old node, in with the new. A morph is reconciliation: the old node stays, and only its changed attributes and children are edited in place. That distinction is the whole tutorial — it's why a live refresh and an open menu can coexist.
The 37signals way
Four conventions agree on a stream name, and multiplayer falls out
Here's the part that should feel almost suspicious: there is no real-time code in Fizzy's board. There's no channel class, no broadcast verb, no event name. There are four small declarations that all happen to name the same thing — the board — and multiplayer is what emerges when they agree.
One. The board page subscribes to a stream named after the board (boards/show.html.erb:5):
<%= turbo_stream_from @board %>
Two. The model declares that it broadcasts refreshes — not appends, not replaces — whenever it changes (card/broadcastable.rb:4-5):
included do
broadcasts_refreshes
end
broadcasts_refreshes is the entire opposite of the naive matrix. It's one macro, and it does not name a verb or a target. It says: "when a record in this model changes, push the word refresh to this model's stream." Create, update, destroy — all of them, one declaration.
Three. The page's <head> declares how to handle an incoming refresh — by morphing, and by preserving scroll position so the board doesn't jump (layouts/shared/_head.html.erb:16):
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
This is the single most important line in the tutorial. method: :morph is what turns "refresh" from "reload the page" into "diff the page." scroll: :preserve is the small grace note that keeps your scroll position through the morph. Set this once, in the layout, and every refresh on every page morphs.
Four — and this is the convention doing the fan-out the naive version did by hand. A card displays its column's name. So when a column changes, every card in it is stale. The naive version wrote a loop to re-broadcast each card. Fizzy writes touch: (column.rb:5, closure.rb:3):
class Column < ApplicationRecord
belongs_to :board, touch: true # column changes → board's updated_at bumps
# ...
after_save_commit -> { cards.touch_all }, if: -> { saved_change_to_name? || saved_change_to_color? }
end
class Closure < ApplicationRecord
belongs_to :card, touch: true # closing a card → the card's updated_at bumps
end
Follow the chain. A Closure is created (someone closes a card). touch: true bumps the card's updated_at. The card broadcasts_refreshes, so a refresh goes out on the board stream. Every subscribed browser re-renders the board and morphs the one card that now shows as closed. Nobody wrote "when a closure is created, update the card on everyone's screen." It fell out of touch: true meeting broadcasts_refreshes meeting turbo_stream_from @board meeting method: :morph. The fan-out the naive loop maintained by hand is now a property of four declarations naming the same board.
"Where's the channel class? Where's the broadcast call? I keep waiting for the real-time code." There isn't any, and that's the point. The "real-time code" is four declarations that each ask the framework the same question — what's this board's stream name? — so they can't disagree. That convention-as-the-glue idea has its deep home in [[see P4: Convention Is Leverage]]; here you just need to see that the entire multiplayer feature is an emergent property of conventions agreeing on a name, not a subsystem you build.
The contrast: the naive version owned a verb-per-change-per-model matrix and a hand-written fan-out loop for every field one record borrows from another. Fizzy owns broadcasts_refreshes (one macro, all verbs), touch: true (the fan-out, declared on the association that already exists), and one turbo_refreshes_with method: :morph line for the whole app. The wire payload shrank from "a fragment and a verb and a target" to the literal word refresh.
Morph as reconciliation, not just refresh
Page-level refresh-on-broadcast is the multiplayer story. But morph earns its keep a second way, on the single user doing the action — and this is where "morph" stops meaning "live refresh" and starts meaning reconciliation.
When you edit a card's title and submit, the HTTP reply is itself a morph, not a replace (cards/update.turbo_stream.erb:1-2):
<% container_partial = @card.drafted? ? "cards/drafts/container" : "cards/container" %>
<%= turbo_stream.replace dom_id(@card, :card_container), partial: container_partial, method: :morph, locals: { card: @card.reload } %>
Read turbo_stream.replace ... method: :morph carefully. It's a replace action — same dom_id target you met in F4a — but the method: :morph flag changes how the swap happens: instead of ripping out the old card node and dropping in a new one, Turbo morphs the old into the new. Why does that matter for the person who just hit save? Because while that round-trip was in flight, their DOM had transient state: focus, a view-transition the browser was animating, a child node they were interacting with. A destructive replace would blow all of it away and flicker. A morph edits only what actually changed — the title text — and leaves the focus and the transition intact. The node you're touching survives the update that touches it.
The same idea drives the drag-and-drop drop response (columns/cards/drops/columns/create.turbo_stream.erb:1):
<%= turbo_stream.replace(dom_id(@column), partial: "boards/show/column", method: :morph, locals: { column: @column }) %>
You dragged a card into a column; the client already moved it optimistically (the card is already in its new spot in your DOM before the server answers). The server's reply re-renders the whole column and morphs it over your optimistic version. If your guess matched the truth — and it usually does — morph finds nothing to change for that card and leaves your optimistic placement, focus, and animation completely alone. The optimistic mutation isn't undone and redone; it's reconciled. This is the exact same shape as Campfire's to_key optimistic-id handshake from [[F4a: the optimistic-id handshake]] — client draws the truth early, server confirms it, and the confirmation is engineered to be a no-op when the guess was right — just reached through morph instead of matched ids. The full worldview lives in [[see P5: One Renderer]]; the drag interaction end-to-end is its own capstone, [[see C/NEW-1: Drag-and-Drop the Rails Way]].
"So is
method: :morpha different thing from the page-levelbroadcasts_refreshes?" Same engine, two entry points.broadcasts_refreshes+turbo_refreshes_with method: :morphmorphs the whole page when a push arrives.turbo_stream.replace ... method: :morphmorphs one node when an HTTP reply arrives. Both say "reconcile, don't destroy." One is pushed to everyone; one is the reply to your own request. The reconciliation behavior is identical — that's why you can use the same partials for both.
Self-healing morphing frames (the gotcha, and the three-line fix)
Page-level morph has a sharp edge, and Fizzy hit it, so you don't have to. Picture a column rendered as a lazy [[F4a: Turbo Frame]] — an empty <turbo-frame src="..."> that fetches its own cards in a second request after the page loads. Now a board refresh arrives and morphs the whole page. The morph is comparing the server's fresh render of that frame against what's currently in your browser. But the server's render of a lazy frame is the empty placeholder — it hasn't loaded the cards; that's the frame's job, lazily, on the client. So a naive morph would happily morph the loaded, full-of-cards frame back into the empty placeholder. The live refresh would erase the very content it was supposed to keep fresh.
Fizzy's column frame helper bakes in the guard (columns_helper.rb:48-55):
def column_frame_tag(id, src: nil, data: {}, **options, &block)
data = data.with_defaults \
drag_and_drop_refresh: true,
controller: "frame",
action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload"
options[:refresh] = :morph if src.present?
turbo_frame_tag(id, src: src, data: data, **options, &block)
end
Look at the action: wiring — that's the Rails-visible contract; the Stimulus controller behind it is background mechanism. Every src frame gets a small controller attached that listens for the morph and, instead of letting the page morph the frame's contents, cancels that morph and re-fetches the frame fresh (frame_controller.js:11-17):
morphReload(event) {
const newElement = event.detail.newElement
if (newElement && newElement.tagName === "TURBO-FRAME" && newElement.matches('[data-controller~="frame"]')) {
event.preventDefault()
this.element.reload()
}
}
In plain terms: "if a page morph is about to overwrite this lazy frame with the server's stale placeholder, stop — and instead reload the frame so it fetches its real, current content." Three lines turn a frame that morph would corrupt into one that heals itself on every refresh: it reloads rather than gets stale-morphed. You don't reason about it per frame; the helper attaches it to every src frame on the board.
The contrast: the naive page-refresh either reloads the whole page (throwing away every lazy frame's loaded state) or naively morphs (overwriting loaded frames with empty placeholders). Fizzy's frame declares, once, in a helper, "I morph my children, but if you try to morph me wholesale, I'll reload instead" — and the corruption-vs-staleness dilemma disappears.
Survive-morph: one attribute vetoes the diff
Here's the subtlest one, and it's the answer to the very first scene in this tutorial: someone has the "move to…" menu open on a card, and a live refresh arrives. Morph compares old DOM to new. In the new (server-rendered) DOM, that menu is closed — the server has no idea you opened it; "open" is pure client state that never went to the database. So morph would dutifully diff open → not-open and close your menu mid-interaction. A live board would constantly slam shut every menu anyone opened.
The fix is a single declared veto. Fizzy uses a native <dialog> whose open/closed state lives in its open attribute, and wires one action in the markup (boards/show/menu/_column.html.erb:50):
<dialog ... data-action="turbo:before-morph-attribute->dialog#preventCloseOnMorphing">
That before-morph-attribute event fires once per attribute morph is about to change, and the handler vetoes exactly one of them (dialog_controller.js:60-65):
preventCloseOnMorphing(event) {
if (event.detail?.attributeName === "open") {
event.preventDefault()
event.stopPropagation()
}
}
Read it as a sentence: "when morph is about to change the open attribute, don't." Every other attribute on that dialog still morphs normally — its contents stay fresh — but the one attribute carrying transient client state (is this menu open?) is protected. A live refresh and an open menu coexist, because the morph is told to skip the single attribute the server can't possibly know about.
"Isn't a server diff overwriting client state exactly the bug morph was supposed to avoid?" It avoids it for everything the server renders. But "is this menu open" is state the server never sees — it's not in the database, so it's not in the server's render. The veto is how you tell morph: this one attribute is mine, not yours — leave it. It's a three-line declaration per piece of transient state, not a reconciliation engine.
Autosave and drafts: derive-don't-store, pointed at client state
Everything so far has been the server keeping screens in sync. The last piece is the client keeping itself honest — and it's worth slowing down on, because the temptation here is to read it as "some JavaScript recipes." It isn't. It's the same instinct as [[see P2: Derive, Don't Store]], turned around to face client state: keep one source of truth, and never let a second copy drift from it.
Start with autosave. The naive approach keeps a dirty boolean: set it true on every keystroke, check it before saving, set it false after. Two pieces of state — the flag and the actual pending-save — that can disagree the instant a save races a keystroke. Fizzy keeps zero extra flags. The dirty bit is the pending timer (auto_save_controller.js:6-47):
export default class extends Controller {
#timer
disconnect() {
this.submit()
}
async submit() {
if (this.#dirty) { await this.#save() }
}
change(event) {
if (event.target.form === this.element && !this.#dirty) { this.#scheduleSave() }
}
#scheduleSave() {
this.#timer = setTimeout(() => this.#save(), AUTOSAVE_INTERVAL)
}
async #save() {
this.#resetTimer() // clears the timer → no longer dirty
await submitForm(this.element)
}
get #dirty() {
return !!this.#timer // "is there a pending save scheduled?" IS "is there unsaved work?"
}
}
There's no dirty flag to keep in sync because "is a save scheduled?" and "is there unsaved work?" are the same question — so #dirty just asks whether the timer exists. And look at disconnect(): when the form leaves the page — you navigated away, the card closed, the node got morphed out — Stimulus calls disconnect(), which flushes the pending save. You get "save on the way out" for free, because the lifecycle hook fires exactly when "the way out" happens. One source of truth (the timer), no second flag to drift, and the flush falls out of the framework's own teardown.
Drafts are the same instinct against a different surface. If you close the card mid-sentence — or a live refresh morphs the editor out from under you — your unsaved text shouldn't vanish. The naive version is a tangle of "save to localStorage, but when do I clear it, and how do I not clobber a saved card with a stale draft?" Fizzy's rules are tight (local_save_controller.js:8-45):
static values = { key: String }
connect() { this.restoreContent() }
submit({ detail: { success } }) {
if (success) { this.#clear() } // clear ONLY on a confirmed-successful save
}
save() {
const content = this.inputTarget.value
if (content) { localStorage.setItem(this.keyValue, content) }
else { this.#clear() }
}
async restoreContent() {
await nextFrame()
let saved = localStorage.getItem(this.keyValue)
if (saved) { this.inputTarget.value = saved; /* re-fire the change event */ }
}
Three decisions carry it, and each is a derive-don't-store judgment. The key is per-resource — the view wires local_save_key_value: "card-#{@card.id}" and "comment-#{card.id}" (cards/edit.html.erb:10, cards/comments/_new.html.erb:10), so each card's and each comment's draft has its own slot and they can't collide. The draft is cleared only on a confirmed-successful submit (if (success)), never optimistically — so a failed save leaves the draft intact to retry, exactly the "don't delete the record until the consequence is durable" discipline you'd want server-side. And it survives morph: the view wires turbo:morph-element->local-save#restoreContent (cards/edit.html.erb:21), so when a live refresh morphs the editor, the draft re-restores itself rather than being lost. The draft in localStorage is the one source of truth for unsaved text; the editor's contents are derived from it on connect and after every morph, and the truth is cleared only when the server confirms it's safe to forget.
"Why does restoring a draft need to re-fire a change event — why not just set the value?" Because the rest of the system (autosave, validation) listens for the editor's change event, not for a silent property assignment — this is HTML-over-the-wire's instinct at the client layer: the event is the truth-carrier, not the value. Set the value silently and the draft would be restored but invisible to everything downstream. The detail is one line; the lesson is that client state, like server state, flows through declared signals, not hidden writes.
The contrast: the naive autosave keeps a dirty flag beside the work it describes (two states that drift), saves drafts but clears them optimistically (lose work on a failed save) or never (clobber a saved card with a stale draft), and loses everything the moment a live refresh morphs the editor. Fizzy keeps one source of truth in each case — the timer is the dirty bit, the localStorage slot is the draft — clears only on confirmed success, and re-derives the visible state after every morph. No second copy, nothing to keep in sync. Derive-don't-store, applied to the browser.
Two asides: the DOM as source of truth
Two small controllers round out the picture; they're footnotes, not units, but they rhyme with everything above.
Autoresize grows a textarea to fit its content without measuring anything in JavaScript. It writes the textarea's current value onto a wrapper data-attribute and lets a CSS trick size an invisible clone (autoresize_controller.js:6-13): the controller's whole job is to keep one data-attribute equal to the textarea's value, and CSS does the actual sizing. The DOM attribute is the source of truth; the layout is derived from it by the stylesheet, not computed by hand. JS keeps one value in sync; CSS does the work.
And the self-submitting, self-deleting form — a form that does its job and then disappears (auto_submit_controller.js:4-22). On connect() it submits itself with requestSubmit() (which, unlike a raw submit(), preserves native validation and lets Turbo intercept), and on a successful turbo:submit-end it removes itself from the DOM. It's the whole Turbo lifecycle — connect, submit, success, remove — expressed as a form that exists only long enough to do one thing. No controller action waiting for it, no cleanup code; the form's own success is the signal to delete it.
The whole journey, on one screen
Trace a single close-a-card action across four browsers. Person A clicks "close." A Closure row is created; touch: true bumps the card's updated_at; the card's broadcasts_refreshes pushes the literal word refresh to the board's stream. Every browser subscribed via turbo_stream_from @board re-requests the board and, because the layout says turbo_refreshes_with method: :morph, morphs the diff — only the closed card changes. On the browser that had a menu open, before-morph-attribute vetoes the open attribute, so the menu stays. On the browser with a lazy column frame, morphReload reloads it instead of morphing stale placeholder over fresh cards. On the browser someone was typing a draft into, restoreContent re-derives the text after the morph. Four screens, one consistent board, every transient client state preserved — and the only "real-time code" anyone wrote was four declarations that agreed on the board's name.
Which principles this serves
F4b is the reconciliation substrate beneath the codebase's highest-conviction frontend ideas. The mechanics live here; the worldview lives in the principles:
- [[see P5: One Renderer, HTML Over the Wire]] — why "the live refresh" and "the HTTP reply" are one feature, the full unpack of morph-as-the-next-generation of the
to_keyhandshake, and why the wire carrying the wordrefreshis the purest form of "the wire carries HTML, not data." - [[see P4: Convention Is Leverage]] — why four declarations agreeing on a stream name is the multiplayer feature; convention as the glue that makes drift impossible.
- [[see P2: Derive, Don't Store]] — the autosave/draft instinct: one source of truth at the client layer, the visible state derived from it, never a second copy kept in sync by hand.
And the morph reconciliation you met here is re-derived end-to-end, as a full interaction with every layer deferring to the next, in the drag-and-drop capstone [[see C/NEW-1: Drag-and-Drop the Rails Way]].
Key Takeaways — Patterns to Steal
When several screens must stay in sync, the reflex is a broadcast verb per kind of change — append on create, replace on update, remove on destroy, times every model — plus a hand-written loop to re-broadcast every record that borrows a field from the one that changed. Don't author that matrix. Declare
broadcasts_refreshes(card/broadcastable.rb:5) once and let the push payload be the literal wordrefresh; the client re-renders and diffs. One macro covers every verb, and the fan-out becomestouch: trueon associations that already exist (column.rb:5,closure.rb:3) — a column change touches the board, a closure touches the card, and the refresh rides the existing graph.A page refresh shouldn't mean a page reload. Set
turbo_refreshes_with method: :morph, scroll: :preserveonce in the layout<head>(layouts/shared/_head.html.erb:16) and every refresh on every page becomes a diff: only the nodes that actually changed get touched, scroll position holds, and the node you're interacting with survives the update.method: :morphis the single line that turns "refresh" from destructive reload into reconciliation.Multiplayer is not a subsystem you build; it's an emergent property of four conventions naming the same thing. The page subscribes with
turbo_stream_from @board(boards/show.html.erb:5), the model declaresbroadcasts_refreshes, the head declaresmethod: :morph, andtouch: truepropagates staleness up the existing associations — no channel class, no event name, no broadcast call. If the four declarations agree on the stream name, they can't drift, and the real-time feature falls out for free.When you reply to a user's own action, use
turbo_stream.replace ... method: :morph(cards/update.turbo_stream.erb:2,columns/cards/drops/columns/create.turbo_stream.erb:1), not a destructive replace. A replace rips out the node and discards the focus, the in-flight CSS transition, and any optimistic mutation the client already drew; a morph edits only what changed and leaves the rest. This is how an optimistic drag is reconciled rather than undone-and-redone — when the client's guess matched the truth, morph finds nothing to change.Page-level morph will corrupt a lazy
srcframe by morphing the server's empty placeholder over the frame's loaded content. Bake the fix into the frame helper: have the frame cancel the wholesale morph and reload itself instead (columns_helper.rb:48-55wires it;frame_controller.js:11-17is the three-linemorphReload). The frame heals itself on every refresh — fetching fresh content rather than being stale-morphed — and you reason about it once, in the helper, not per frame.A live refresh will slam shut every menu and collapse every panel, because "open" and "collapsed" are client state the server's render can't know. Protect exactly the one attribute carrying that transient state with a
turbo:before-morph-attributeveto (dialog_controller.js:60-65vetoesopen;collapsible_columns_controller.js:39-43vetoesclass). Every other attribute still morphs fresh — it's a three-line declaration per piece of browser-only state, not a reconciliation layer.Don't keep a
dirtyboolean next to the work it describes; the flag and the work drift the instant a save races a keystroke. Let the pending-save timer be the dirty bit —#dirtyjust asks whether the timer exists (auto_save_controller.js:45-47) — and put the flush indisconnect()(auto_save_controller.js:11-13) so "save on the way out" falls out of the framework's own teardown. One source of truth, no second flag, and the flush is free.Persist client-side drafts the way you'd persist anything server-side: one source of truth, cleared only when the consequence is durable. Key the draft per resource (
local_save_key_value: "card-#{@card.id}",cards/edit.html.erb:10) so drafts can't collide, clear it only on a confirmed-successful submit (local_save_controller.js:16-20) so a failed save keeps the draft to retry, and re-derive the visible text after every morph (turbo:morph-element->local-save#restoreContent,cards/edit.html.erb:21) so a live refresh never eats unsaved work. The localStorage slot is the truth; the editor is derived from it — derive-don't-store, applied to the browser.