60 · the ladder, filled in
You climbed this.
The same question, answered six times. Every row is something you did with your own hands. Click a name to go back to it.
| tool | throws away | keeps | who decides | blast radius |
|---|---|---|---|---|
| page load | the whole tab process | nothing | the browser | |
| Turbo Drive | the <body> | the JS process, loaded CSS/JS, data-turbo-permanent elements | Turbo, for every same-site link | |
| morphing | only nodes that changed | scroll, typed text, everything the HTML agrees on | the diff between old and new HTML | |
| Turbo Frames | the inside of one box | the rest of the page, and the URL | the id you put on both pages | |
| Turbo Streams | named elements, wherever they are | everything else; no navigation at all | the server, per element | |
| Stimulus | nothing | —; it attaches behaviour to whatever HTML lands | the HTML, via data-controller | |
| Native | nothing on the web side | every page; wraps them in a native stack | the path configuration |
Dashed bar: no web-side change, a native shell around it. Not on the table because they're not tools: the <head> merge, the cache, and the 303 rule. They're the ground the tools stand on.
60 · the rule
Four questions pick the tool.
| ask | if yes |
|---|---|
| Is it a navigation to another page? | Drive. It's already on. Done. |
| Is it a navigation back to the page you're on? | morph refresh, so nothing jolts |
| Does one region need to navigate on its own? | a Frame around that region |
| Must several places change, or other people's screens, or nothing navigate at all? | Streams: from the response, or broadcast over the socket |
| Is no server involved? | Stimulus |
| Is it a phone? | Native, wrapping all of the above |
Ask them in order; stop at the first yes. Now try it on twelve real situations.
60 · the decision desk
Twelve situations from a real Rails app.
Place your bet, then read why. No score matters; the reasons do.
your bets
60 · sandbox
Every switch, at once.
Ask your own question. The model is honest about what it does, and honest about what it isn't: the morph is id-keyed and simpler than idiomorph, there's no real network so no latency games, and there's no WebSocket here (that lives in 32). Flip anything; the next request picks it up.
Questions to try:
60 · cheat sheet
One card. Everything you touched.
Drive
- data-turbo="false"
- hand a link, form, or container back to the browser
- data-turbo-action="advance | replace"
- push or replace history; on a frame, promotes its navigations to real visits
- data-turbo-permanent + id
- carry the node across renders; also excluded from morphs
- data-turbo-temporary
- remove before caching (flash messages)
- data-turbo-track="reload"
- on fingerprinted assets: full reload when they change
- data-turbo-method / data-turbo-confirm
- non-GET links, with a confirm
- data-turbo-submits-with
- label while submitting
- data-turbo-prefetch="false"
- opt a link out of hover prefetch
- data-turbo-preload
- warm the cache for a link before it's clicked
Meta tags
- turbo-refresh-method=morph
- refreshes morph instead of replacing body
- turbo-refresh-scroll=preserve
- keep scroll on refresh
- turbo-visit-control=reload
- this page always does a full load (login pages; frame breakout)
- turbo-cache-control=no-cache | no-preview
- opt a page out of the cache or just out of previews
- turbo-prefetch=false
- disable hover prefetch site-wide
- turbo-root
- scope Drive to a path prefix
- view-transition=same-origin
- animate with the View Transition API
Frames
- turbo_frame_tag model
- <turbo-frame id="dom_id">; same id on both sides
- src="/path"
- eager-load the frame after the page
- loading="lazy"
- load when scrolled into view
- target="_top"
- links inside drive the page, not the frame
- data-turbo-frame="id | _top | _self"
- on a link or form: which frame to drive
- refresh="morph"
- morph the frame on page refresh instead of reloading it
- turbo_page_requires_reload
- Rails helper for visit-control reload (login page)
- turbo:frame-missing
- event when the response had no matching frame
Streams
- append prepend replace update remove before after refresh
- the eight actions; method="morph" on replace/update
- target="id" / targets="css"
- one element by id, or many by selector
- format.turbo_stream
- respond_to branch; renders create.turbo_stream.erb
- turbo_stream.append "messages", @message
- reuse the _message partial
- data-turbo-stream
- accept streams on a GET link or form
- turbo_stream_from @board
- subscribe the page over Action Cable
- broadcasts_to :board / broadcasts_refreshes
- model-side: precise streams, or a morph refresh
Forms
- 303 See Other
- after a successful non-GET, redirect
- 422 Unprocessable Content
- to re-render a form with errors
- 200 from POST
- ignored by Turbo, on purpose
- turbo:submit-start / submit-end
- events on the form
Stimulus
- data-controller="name"
- name_controller.js attaches here
- data-name-target="x"
- static targets = ["x"] → this.xTarget, xTargets, hasXTarget
- data-action="event->name#method"
- event optional for button/form/input; .esc, @window, :prevent, :stop
- data-name-x-value
- static values = { x: Number } → this.xValue, xValueChanged()
- data-name-x-class
- static classes = ["x"] → this.xClass
- data-name-x-param
- event.params.x, read from the element carrying the action
- data-name-other-outlet=".sel"
- static outlets = ["other"] → this.otherOutlet(s)
- initialize connect disconnect
- lifecycle; [x]TargetConnected too
- this.dispatch("event")
- fires name:event for other controllers
Native
- path configuration
- JSON rules by URL regex: context modal, presentation replace/pop/clear_all/refresh
- data-turbo-action="replace"
- replace the top screen
- BridgeComponent + static component
- a Stimulus controller that talks to Swift/Kotlin via this.send()
- data-bridge-title, data-bridge-disabled
- element hints for the native side
- data-controller-optout-ios/android
- disable a component per platform