Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 5 – Hotwire: Turbo, Stimulus and real-time

Turbo Drive and Turbo Frames

Follow Turbo in a Rails 8.1 app: the Drive body swap, the statuses forms must return, inline editing in a frame, lazy frames and morphing refreshes.

  • Intermediate
  • 15 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Trace one Turbo Drive visit from a click through the head merge and body swap to the browser history
  • Explain the statuses a form must return to Turbo, and when to opt out, track assets or let links prefetch
  • Use a Turbo Frame to scope navigation, including inline editing, lazy loading and breaking out to the whole page
  • Decide when a page refresh should morph, and add the focus and live region a frame swap needs

Before you start

What you’ll learn · Before you start

What you will learn

  • Trace one Turbo Drive visit from a click through the head merge and body swap to the browser history
  • Explain the statuses a form must return to Turbo, and when to opt out, track assets or let links prefetch
  • Use a Turbo Frame to scope navigation, including inline editing, lazy loading and breaking out to the whole page
  • Decide when a page refresh should morph, and add the focus and live region a frame swap needs

Before you start

On this page

A visitor edits one batch name in Kaksha, the coaching-centre app this track builds. Without Turbo, saving that one field reloads the whole page: 28 middleware, the full layout and every stylesheet and script, just to change three words in a table. Turbo Drive and Turbo Frames remove that cost while you keep writing plain, server-rendered Rails.

This lesson follows recorded requests through a Rails 8.1 app running turbo-rails 2.0.23 (Turbo 8.0.23). You will trace one Drive visit, see the statuses your forms must return, scope an edit to a single row with a frame, defer work with a lazy frame, keep a refresh smooth with morphing, and add the focus and announcements a frame swap leaves out.

Turbo Drive turns a click into a visit

A new Rails app loads Turbo in app/javascript/application.js, so Drive is on from the first page. Turbo Drive: The part of Turbo that intercepts link clicks and form submissions, fetches the page in the background, and updates it by swapping the body and merging the head instead of a full reload. watches for link clicks and form submissions, performs them in the background, and updates the page without a full reload. It models each one as a visit to a URL.

Follow one visit to /batches/7, from the click to the rendered page:

Interactive · stage by stage One Turbo Drive visit, from click to render Step through what Turbo Drive does after a click, without a full page reload.
One Turbo Drive visit, from click to render, step 5 of 5. Showing Click or submit, Fetch in background, Merge the head, Swap the body, Update URL and cache. In focus: Update URL and cache.Click or submita link or a formFetch in backgroundone GET or POSTMerge the headkeep matching assetsSwap the bodyrender the responseUpdate URL and cachean advance visit

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A click starts a visit. A visitor clicks a link that stays on the same origin. Turbo Drive catches the click instead of letting the browser leave the page.
  2. Fetch, page still on screen. Turbo fetches the target URL in the background. The current page stays visible, and a thin progress bar appears if the request takes longer than half a second.
  3. Merge the head. When the response arrives, Turbo merges the two documents' head elements. A stylesheet or script whose URL has not changed is left alone, so the browser never re-parses it.
  4. Swap the body. Turbo replaces the current body with the response body and updates the html lang attribute if it differs. The page is now the new one, without a reload.
  5. Record the visit. Turbo pushes a new browser history entry for an advance visit and keeps a copy of the page in its cache, so the next visit here can show a preview at once.

The render step is the heart of it. Turbo replaces the current document’s <body> with the response’s <body>, merges the two <head> elements, and updates the <html> lang attribute if it changed. Merging the head matters: a stylesheet or script whose URL has not changed is left untouched, so the browser never parses it again, which is why a Turbo visit feels so much lighter than a reload.

A visit carries an action. The default, advance, pushes a new browser history entry; replace swaps the current one instead, chosen with data-turbo-action="replace". Pressing Back or Forward triggers a third kind, a restoration visit, which Turbo keeps for itself: it renders a cached copy of the page when it can, restores the saved scroll position, and cannot be cancelled. While a visit is in flight, Turbo shows a thin progress bar after 500 ms and sets aria-busy="true" on the <html> element until it completes.

Key idea

A Turbo Drive visit swaps the response body into the page and merges the head, so unchanged assets are never re-parsed; an advance visit adds a history entry, a replace visit overwrites one, and Back and Forward restore a cached page.

Checkpoint

Check: What Turbo Drive does to a page

  1. One question After a Turbo Drive visit renders, what has Turbo changed in the current document?

    Choose one answer.

    Show the answer: One question

    Answer: It swapped in the response body and merged the head, leaving unchanged assets in place.

    During a visit Turbo Drive replaces the requesting document's body with the response's body, merges the two heads, and updates the html lang attribute when it changes. Merging the head means a stylesheet or script whose URL is unchanged is left in place, so the browser does not process it again.

The statuses your forms must return

Turbo handles a form submission much like a link click, except that a form can send a stateful POST, PATCH or DELETE. That changes what the server must answer. After a successful non-GET request, Turbo expects a 303 See Other redirect, which it follows with a fresh GET so the PATCH is never repeated. When a save fails validation, the server re-renders the form with 422 Unprocessable Content, and Turbo shows that response in place.

The form did The server returns Why
A GET that renders a page 200 OK Normal navigation; Turbo swaps the body
A POST/PATCH/DELETE that succeeds 303 See Other, with a Location Turbo follows it with a GET, so a reload never resubmits
A POST/PATCH/DELETE that fails validation 422 Unprocessable Content Turbo renders the returned form, with its errors, in place
A request that hits a server error 500 Internal Server Error Turbo renders it, so a “something went wrong” page shows

Turbo refuses to render a plain 200 from a non-GET on purpose: browsers warn before reloading a POST, and Turbo cannot replicate that dialogue, so it insists on the 303 that lands the visitor on a safe-to-reload GET URL. The HTTP status codes reference lists each code and its meaning.

The inline edit below runs these statuses for real: an integration test posts a blank name and a valid name to a controller and prints what Turbo would receive.

A real 422 on an invalid save and 303 after a valid one Ruby · sessions/inline_edit_session.rb
# Inline editing inside a Turbo Frame, run as an integration test on the fixture
# app: db:prepare builds the development database and dumps the schema, then the
# test boots Rails, makes the three requests and prints what Turbo would receive.
require_relative "session"

Session.run(<<~SH, masks: [[/-> \d+\.\d+s/, "-> …s"]])
  cd ../kaksha
  bin/rails db:prepare
  bin/rails test test/integration/inline_batch_edit_test.rb
SH

Output

$ cd ../kaksha
$ bin/rails db:prepare
== 20260101000001 CreateBatches: migrating ====================================
-- create_table(:batches)
   -> …s
== 20260101000001 CreateBatches: migrated (…s) ===========================

== 20260101000001 CreateBatches: migrating ====================================
-- create_table(:batches)
   -> …s
== 20260101000001 CreateBatches: migrated (…s) ===========================

$ bin/rails test test/integration/inline_batch_edit_test.rb
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed …

# Running:

GET /batches/1/edit -> 200
the frame Turbo swaps into the row:
<turbo-frame id="batch_1"> <form action="/batches/1" accept-charset="UTF-8" method="post"><input type="hidden" name="_method" value="patch"> <label for="batch_name">Name</label> <input type="text" value="Class 10 Science" name="batch[name]" id="batch_name"> <label for="batch_teacher">Teacher</label> <input type="text" value="Meera Nair" name="batch[teacher]" id="batch_teacher"> <input type="submit" name="commit" value="Save" data-disable-with="Save"> </form></turbo-frame>
PATCH with a blank name -> 422 Unprocessable Content (Turbo re-renders the frame)
PATCH with a valid name -> 303 See Other, Location http://www.example.com/batches
.

Finished in …s, … runs/s, … assertions/s.
1 runs, 7 assertions, 0 failures, 0 errors, 0 skips

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby inline_edit_session.rb

The blank name came back as 422 Unprocessable Content and the valid name as 303 See Other, exactly as the table promises. The controller that produces them is three lines of ordinary Rails:

BatchesController: 303 on success, 422 on an invalid save Ruby · kaksha/app/controllers/batches_controller.rb
class BatchesController < ApplicationController
  # The list page. Each row is its own Turbo Frame, so editing one row
  # leaves every other row untouched.
  def index
    @batches = Batch.order(:id)
  end

  # The detail page, which carries a lazy attendance frame.
  def show
    @batch = Batch.find(params[:id])
  end

  # The form that replaces one row's frame when "Edit" is clicked.
  def edit
    @batch = Batch.find(params[:id])
  end

  def update
    @batch = Batch.find(params[:id])
    if @batch.update(batch_params)
      # A non-GET redirect Turbo follows must be 303, so the browser issues
      # the next request as a GET rather than repeating the PATCH.
      redirect_to batches_path, status: :see_other
    else
      # A 4xx tells Turbo to render the returned frame in place, so the form
      # comes back with its error messages instead of navigating away.
      render :edit, status: :unprocessable_content
    end
  end

  # The lazy frame's own request: it renders only the attendance figures.
  def attendance
    @batch = Batch.find(params[:id])
  end

  private
    def batch_params
      params.expect(batch: [:name, :teacher])
    end
end

Checkpoint

Check: The statuses your forms must return

  1. Question 1 of 2 A batch saves successfully from a form Turbo submitted with PATCH. Which status should the controller return so Turbo navigates correctly?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 303 See Other, with a Location to follow

    Turbo follows a 303 after a non-GET by issuing a GET to the Location, which updates the page without the browser's resubmit dialogue. Rails 8.1 scaffolds already redirect with status: :see_other for this reason.

  2. Question 2 of 2 A save fails validation and the controller re-renders the form. Which status tells Turbo to show that form in place?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 422 Unprocessable Content

    Turbo renders 4xx and 5xx responses so that validation errors (422) and server errors (500) appear in the page. render :edit, status: :unprocessable_content is the Rails way to send the form back with its errors.

Telling Turbo to step aside, reload assets or prefetch

Sometimes you want the browser, not Turbo, to handle a link, and sometimes you want Turbo to work harder. Three attributes cover almost every case:

Attribute Put it on Effect
data-turbo="false" A link, form, or any ancestor The browser handles it as a normal, full navigation; data-turbo="true" re-enables Turbo inside an opted-out region
data-turbo-track="reload" A <link> or <script> in the head A full reload when that asset’s URL changes, so new CSS or JavaScript is not layered on the old copy
data-turbo-prefetch="false" A link or ancestor Turns off hover prefetch for that element

Asset tracking needs a version in the URL. Rails adds one for you: the generated layout writes stylesheet_link_tag :app, "data-turbo-track": "reload", and Propshaft gives the file a content digest, so a deploy changes the digest, the merged head shows a different URL, and Drive does one full reload to pick up the change cleanly.

The last attribute turns off a feature that is on by default. Since Turbo 8, a link prefetch: Turbo loading a link's page in the background when the pointer rests on it, before the click, so the navigation feels instant; on by default since Turbo 8. on hover: after the pointer rests on it briefly (about 100 ms, so a passing cursor does not trigger it), Turbo loads the page in the background, so part of the wait is over before the click lands. Switch it off per element with data-turbo-prefetch="false", or page-wide with a <meta name="turbo-prefetch" content="false">, for links to pages that are expensive to render.

Checkpoint

Check: Opting out, asset tracking and prefetch

  1. Question 1 of 2 What does data-turbo="false" on a link do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It lets the browser handle that link as a normal, full navigation.

    data-turbo="false" on an element, or any ancestor, disables Turbo there; data-turbo="true" re-enables it inside an opted-out region. Use it for links and forms that must behave as a plain browser navigation.

  2. Question 2 of 2 A stylesheet tag carries data-turbo-track="reload" and its URL includes a content digest. What happens when you deploy a new version of that file?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The next visit sees a new digest in the head and triggers a full page reload.

    Tracked assets must carry a version in their URL (a digest is best). When the merged head shows a different tracked URL, Drive performs a full reload so changed CSS or JavaScript is not layered on top of the old copy.

Turbo Frames scope navigation to one region

Drive swaps the whole page. A Turbo Frame: A turbo-frame element with a unique id that captures the links and forms inside it, so a response updates only that region of the page and leaves the rest untouched. swaps one region of it. Wrap a segment in a <turbo-frame> with a unique id, and every link and form inside it is captured: the response is fetched in the background, the <turbo-frame> with the matching id is extracted from it, and only that frame’s contents are replaced. The rest of the page, its scroll and its focus elsewhere, stay exactly as they were.

That is what makes inline editing cheap. Each batch row is its own frame, named with dom_id(batch), and the “Edit” link lives inside it. Step through one row as it becomes a form and returns:

Interactive · message by message Editing one row inside its Turbo Frame Follow one batch row as it becomes a form and returns, while the rest of the page stays put.
Editing one row inside its Turbo Frame, step 8 of 8. Messages so far: Turbo to Rails: GET /batches/1/edit; Rails to Turbo: a frame with a form; Turbo to Rails: PATCH /batches/1; Rails to Turbo: 422 and the form; Rails to Turbo: 303 then the new row.Visitorbatch rowTurbobrowserRailscontrollerGET /batches/1/edita frame with a formPATCH /batches/1422 and the form303 then the new row

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Click Edit in the frame. The row sits inside a turbo-frame with id batch_1. The visitor clicks the Edit link, which lives inside that frame.
  2. Turbo fetches the form. Because the link is inside a frame, Turbo scopes the request to it. It fetches the edit page in the background rather than leaving the page.
  3. Server returns a matching frame. Rails renders a turbo-frame with the same id, batch_1, this time holding the edit form. Only that frame is read from the response.
  4. Swap the form into the row. Turbo replaces the row's contents with the form. Every other row, and the rest of the page, is untouched, so scroll and focus elsewhere are kept.
  5. Edit and submit. The visitor changes the name and submits the form, which also lives inside the frame, so its submission stays scoped to batch_1.
  6. Turbo sends the change. Turbo submits the form as a PATCH request, again in the background and scoped to the frame.
  7. An invalid name stays 422. If the name is blank, the server re-renders the frame with the error messages and status 422, and Turbo shows the form again in place.
  8. A valid name redirects 303. If the name is valid, the server answers 303 and Turbo follows it, fetching the list and rendering the updated row back in the frame.

In Rails, the turbo_frame_tag helper from turbo-rails writes the frame, and the edit response reuses the same id, this time holding the form:

The row partial: each row is a frame named dom_id(batch) ERB · kaksha/app/views/batches/_batch.html.erb
<%# Each row is wrapped in its own Turbo Frame, named with dom_id(batch). %>
<%# A link or form inside the frame navigates only this frame, not the page. %>
<%= turbo_frame_tag dom_id(batch) do %>
  <div class="batch-row">
    <span class="name"><%= batch.name %></span>
    <span class="teacher"><%= batch.teacher %></span>
    <%= link_to "Edit", edit_batch_path(batch) %>
  </div>
<% end %>
The edit response: the same frame id, now a form ERB · kaksha/app/views/batches/edit.html.erb
<%# The response reuses the SAME frame id as the row, so Turbo swaps the %>
<%# row's display for this form in place. %>
<%= turbo_frame_tag dom_id(@batch) do %>
  <%= form_with model: @batch do |form| %>
    <% if @batch.errors.any? %>
      <ul class="errors">
        <% @batch.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    <% end %>

    <%= form.label :name %>
    <%= form.text_field :name %>
    <%= form.label :teacher %>
    <%= form.text_field :teacher %>
    <%= form.submit "Save" %>
  <% end %>
<% end %>

An integration test drives the whole cycle without a browser, reading the frame that Turbo would swap in and checking the 422 and 303 along the way:

The inline-edit cycle as an integration test Ruby · kaksha/test/integration/inline_batch_edit_test.rb
require "test_helper"

# Inline editing inside a Turbo Frame, proved by a real request-response cycle:
# the frame comes back with a form, an invalid save answers 422, and a valid
# save answers 303. No browser is involved; the integration test is the client.
class InlineBatchEditTest < ActionDispatch::IntegrationTest
  setup { @batch = batches(:science) }

  test "a row's frame returns a form, errors stay 422, a save redirects 303" do
    frame = "turbo-frame#batch_#{@batch.id}"

    get edit_batch_path(@batch)
    assert_response :success
    assert_select "#{frame} form"
    puts "GET #{edit_batch_path(@batch)} -> #{response.status}"
    puts "the frame Turbo swaps into the row:"
    puts css_select(frame).first.to_html.gsub(/\s+/, " ").strip

    patch batch_path(@batch), params: { batch: { name: "" } }
    assert_response :unprocessable_content
    assert_select "#{frame} ul.errors li"
    puts "PATCH with a blank name -> #{response.status} " \
         "#{response.status_message} (Turbo re-renders the frame)"

    patch batch_path(@batch), params: { batch: { name: "Class 10 Physics" } }
    assert_response :see_other
    puts "PATCH with a valid name -> #{response.status} " \
         "#{response.status_message}, Location #{response.headers['Location']}"
  end
end

Navigation can also leave the frame. Set target="_top" on the frame, or data-turbo-frame="_top" on one link or form, and that navigation drives the whole page instead; name another frame’s id to drive that frame. And data-turbo-action="advance" on a frame promotes its navigation to a full visit, so paging through a list inside a frame updates the address bar and history too.

A frame request whose response has no matching frame

If the response is missing a <turbo-frame> with the frame’s id, Turbo writes an error into the frame and throws. When a request should genuinely leave the frame, such as an expired session redirecting to a login page, mark that page with <meta name="turbo-visit-control" content="reload"> (the turbo_page_requires_reload helper) so it always becomes a full-page navigation.

Checkpoint

Check: How a Turbo Frame scopes navigation

  1. Question 1 of 2 A link inside a frame with id batch_1 is clicked. What must the response contain for the frame to update?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A turbo-frame element with the same id, batch_1

    A frame request expects a response that includes a turbo-frame with the same id. Turbo reads just that element and replaces the frame's contents, ignoring the rest of the response document.

  2. Question 2 of 2 You want one link inside a frame to replace the whole page instead of just the frame. What do you set?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: target="_top" on the frame, or data-turbo-frame="_top" on the link

    By default navigation inside a frame targets that frame. Set target="_top" on the frame, or data-turbo-frame on a specific link or form, to send a navigation to the whole page or to another named frame.

Lazy frames defer work until a panel is shown

A frame with a src attribute eager-loads: it fetches that URL as soon as the frame appears on the page. Add loading="lazy" and the fetch waits until the frame becomes visible, exactly like loading="lazy" on an <img>. It is the right tool for content behind a modal, below the fold or inside a closed disclosure.

Kaksha’s batch page ships a placeholder for attendance and loads the real figures only when that frame is shown:

A lazy frame: the placeholder ships with the page ERB · kaksha/app/views/batches/show.html.erb
<h1><%= @batch.name %></h1>

<%# A lazy frame: Rails ships the placeholder with the page, and the browser %>
<%# fetches src only when the frame scrolls into view. %>
<%= turbo_frame_tag dom_id(@batch, :attendance),
                    src: attendance_batch_path(@batch),
                    loading: :lazy do %>
  <p>Loading attendance…</p>
<% end %>
The frame's own response: a matching frame id with the figures ERB · kaksha/app/views/batches/attendance.html.erb
<%# The frame's own request answers with a matching frame id, so Turbo drops %>
<%# these figures into the placeholder above. %>
<%= turbo_frame_tag dom_id(@batch, :attendance) do %>
  <p><%= @batch.present_count %> of <%= @batch.total_count %> present
     (<%= @batch.attendance_percent %>%)</p>
<% end %>

The lazy frame makes two trips: the page carries the placeholder and the frame’s src, then a second request loads the frame’s own body. An integration test makes exactly those two requests:

The placeholder first, then the frame's own response Ruby · sessions/lazy_frame_session.rb
# A lazy frame fetched as its own request, run as an integration test: the page
# ships a placeholder, then a second request loads the frame's own body.
require_relative "session"

Session.run(<<~SH, masks: [[/-> \d+\.\d+s/, "-> …s"]])
  cd ../kaksha
  bin/rails db:prepare
  bin/rails test test/integration/lazy_attendance_frame_test.rb
SH

Output

$ cd ../kaksha
$ bin/rails db:prepare
== 20260101000001 CreateBatches: migrating ====================================
-- create_table(:batches)
   -> …s
== 20260101000001 CreateBatches: migrated (…s) ===========================

== 20260101000001 CreateBatches: migrating ====================================
-- create_table(:batches)
   -> …s
== 20260101000001 CreateBatches: migrated (…s) ===========================

$ bin/rails test test/integration/lazy_attendance_frame_test.rb
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed …

# Running:

GET /batches/1 (the page) ships:
<turbo-frame loading="lazy" id="attendance_batch_1" src="/batches/1/attendance"> <p>Loading attendance…</p> </turbo-frame>
GET /batches/1/attendance (the frame's own request) returns:
<turbo-frame id="attendance_batch_1"> <p>27 of 30 present (90%)</p> </turbo-frame>
.

Finished in …s, … runs/s, … assertions/s.
1 runs, 6 assertions, 0 failures, 0 errors, 0 skips

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby lazy_frame_session.rb

The page shipped the frame with loading="lazy" and its src; the second request returned a matching frame with “27 of 30 present (90%)”. While the frame fetches, Turbo sets aria-busy="true" on it and removes it when done. A lazy frame whose panel stays closed never costs its request at all. Frames also help caching: put the part of a page that differs per user in its own frame, and the rest can come from one shared cache entry. Frames that are visible on load are another matter: each is one more request, and the page can shift as their content arrives, so keep those few.

Checkpoint

Check: Lazy frames and their cost

  1. One question A frame has both a src and loading="lazy". When does Turbo fetch the src?

    Choose one answer.

    Show the answer: One question

    Answer: When the frame becomes visible on the page

    A src on a turbo-frame eager-loads as soon as the element appears. Adding loading="lazy" defers the fetch until the frame is visible, which is ideal for content hidden behind a modal or below the fold.

Page refreshes that morph instead of replacing

A common flow is to submit a form and be redirected back to the same page, which then refreshes itself. By default that replaces the whole <body> and jumps the scroll to the top. morphing: A page-refresh method in which Turbo updates only the DOM nodes that changed, keeping the rest of the page in place, instead of replacing the whole body. does better: it compares the new DOM with the current one and updates only the nodes that changed, leaving the rest in place.

Refresh method What happens Scroll
replace (the default) The whole body is replaced with the new one Resets to the top, unless preserved
morph Only the DOM nodes that differ are updated, keeping the rest preserve keeps the vertical and horizontal scroll

You opt in with two meta tags, <meta name="turbo-refresh-method" content="morph"> and <meta name="turbo-refresh-scroll" content="preserve">. The turbo-rails helper turbo_refreshes_with(method: :morph, scroll: :preserve) writes both for you. Flag any element that must survive the morph untouched, such as an open popover, with data-turbo-permanent. To push a change to several parts of a page at once, or to update other visitors’ screens live, Turbo Streams take over; the next lesson, Turbo Streams and model broadcasts, covers them.

Checkpoint

Check: Page refreshes with morphing

  1. One question A page sets the meta tags turbo-refresh-method="morph" and turbo-refresh-scroll="preserve". What changes when the page refreshes itself?

    Choose one answer.

    Show the answer: One question

    Answer: Only the DOM elements that changed are updated, and the scroll position is kept.

    With turbo-refresh-method="morph" a page refresh updates only the DOM nodes that differ, keeping the rest, and turbo-refresh-scroll="preserve" keeps the scroll position, so the screen barely flickers.

Frame swaps need focus and an announcement from you

A frame swap is quiet. Turbo toggles aria-busy while it fetches and announces nothing when the new content lands. It moves keyboard focus only when that content holds a focusable element marked autofocus; otherwise focus stays where it was, which can be a link that no longer exists. A sighted visitor sees the row become a form; a screen-reader user is told nothing.

After a frame swap What Turbo does What you must add
Keyboard focus Focuses the first autofocus element of the new content, if any Mark the form’s first field autofocus; move focus back to the row after a save
Screen-reader announcement Nothing Announce a short message in an aria-live="polite" region
Loading state Sets aria-busy="true", then removes it Nothing; this one is handled

So the inline edit is not finished when the 303 lands. Give the edit form’s first field autofocus, so focus lands in the form when it arrives. When the row returns, move focus to it and write a brief message such as “Batch saved” into a polite live region, so keyboard and screen-reader users get the same feedback a sighted user gets from the row changing in place. A small Stimulus controller is the usual home for that work, which the Stimulus controllers lesson builds.

Relying on the visible change alone

Signalling a save only by recolouring the row, or by a toast that vanishes after a second, leaves out anyone who cannot see it or who reads at their own pace. Focus management plus a polite live region reaches everyone.

Checkpoint

Check: The accessibility duty of frames

  1. One question A frame swap replaces a row with a form. For a screen-reader user, what do you need to add so the change is noticed?

    Choose one answer.

    Show the answer: One question

    Answer: Move focus into the new content, and announce changes through a polite live region.

    Turbo sets aria-busy="true" on a frame while it fetches, but it reads nothing out and moves focus only to an autofocus element. When a user needs to know something changed, move focus to the result and announce it in an aria-live="polite" region.

Practise

These questions come from this lesson’s runs and tables: the Drive visit, the statuses the inline edit returned, the frame cycle, the lazy attendance frame, and the accessibility duty. Pick an answer and its explanation opens at once:

Practice that adapts to you

Practise this lesson

12 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Which visit action pushes a new entry onto the browser history stack?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: advance

    Turbo has two application visit actions, advance (the default, pushes history) and replace (swaps the top entry), plus restore, which Turbo uses internally for Back and Forward.

  2. Question 2 of 12 A visitor presses the browser Back button. What kind of visit is this, and can your code cancel it?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: A restoration visit, which cannot be cancelled and may render from cache without a request.

    Back and Forward trigger restoration visits. They are reserved for Turbo, render a cached page when possible, and restore the saved scroll position; they cannot be cancelled and do not fire turbo:before-visit.

  3. Question 3 of 12 Why does Turbo refuse to render a 200 response to a POST, and stay on the current URL instead?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Because reloading a rendered POST would make the browser offer to resubmit the form, which Turbo cannot replicate.

    Browsers warn before reloading a POST. By requiring a 303 redirect, Turbo lands the visitor on a GET URL that is safe to reload. A 4xx or 5xx is the exception Turbo renders, for validation errors and server failures.

  4. Question 4 of 12 In Turbo 8, when does a link prefetch by default, and how do you stop it for one expensive link?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: It prefetches after the pointer rests on it briefly; add data-turbo-prefetch="false" to opt that link out.

    Since Turbo 8, links prefetch on hover, after a short pause so a passing cursor does not trigger it, which usually saves a few hundred milliseconds per click. Disable it per element with data-turbo-prefetch="false", or page-wide with a meta tag.

  5. Question 5 of 12 A link sits inside a turbo-frame. By default, what does clicking it navigate?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Only that frame

    Each turbo-frame establishes its own navigation context. Links and forms inside it update that frame alone, which is what lets one row edit itself while the rest of the page stays put.

  6. Question 6 of 12 A paginated list lives in a frame. You want the address bar to update as the visitor pages through it. What do you add?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: data-turbo-action="advance" on the frame or the paging links

    data-turbo-action="advance" on a frame, or on the links and forms that drive it, turns the frame navigation into a visit, so the path and history update. Your controller must then render that state from the URL on a refresh.

  7. Question 7 of 12 While a lazy frame is fetching its contents, which attribute does Turbo set on the frame?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: aria-busy="true"

    A frame carries aria-busy="true" while it loads and gains a complete attribute when done. aria-busy helps, but it announces nothing about the new content, which is a separate duty.

  8. Question 8 of 12 The handbook says loading frames add almost no cost in one situation. Which is it?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: When the content is not visible on load, such as behind a modal or below the fold.

    Lazy frames shine for content hidden on load. Still, be judicious with eager loading frames that are visible, because each adds a request and can cause load-in jitter; frames also improve cache dynamics by splitting segments.

  9. Question 9 of 12 You have a popover that must stay open through a morphing page refresh. What do you mark it with?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: data-turbo-permanent

    Flag elements you want kept across a morph with data-turbo-permanent, such as an open popover or a media player, and Turbo will not morph them.

  10. Question 10 of 12 Morphing helps most in one common flow. Which one?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Submitting a form and being redirected back to the same page, which then refreshes itself.

    A classic page refresh is submitting a form and getting redirected back. Morphing updates only what changed and preserve keeps the scroll, so the screen barely moves; turbo-rails can also broadcast a refresh with broadcasts_refreshes.

  11. Question 11 of 12 A row swaps to a form inside a frame. By default, what does a screen reader announce about the change?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Nothing; the swap is silent

    Frames update quietly. aria-busy toggles during the fetch, but nothing is read out, so a screen-reader user can miss that a row became a form.

  12. Question 12 of 12 After an inline edit saves and the row returns, how do you tell a keyboard and screen-reader user what happened?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Move focus to the updated row and announce a short message in an aria-live="polite" region.

    Because a frame swap is silent, move focus to the result and announce it in an aria-live="polite" region. That gives keyboard and screen-reader users the same feedback a sighted user gets from the row changing in place.

Interview questions

Warm-up: a form in a Rails app submits with Turbo. What status should the controller return on a successful save, and what on a validation error, and why those two?

On a successful non-GET save, return a 303 See Other redirect; on a validation error, re-render the form with 422 Unprocessable Content. Turbo follows a 303 with a fresh GET, so a reload never resubmits the POST or PATCH, which is the behaviour a browser’s own “resubmit this form?” dialogue would otherwise guard. Turbo renders 4xx and 5xx responses in place, so a 422 brings the form back with its error messages, and a 500 shows a failure page. A plain 200 from a non-GET is the one thing Turbo will not navigate to, because it has no safe way to handle a reload of it. Rails 8.1 scaffolds already use status: :see_other and status: :unprocessable_content for exactly this.

The module’s question bank builds two longer cases on this lesson’s runs: the statuses a frame form must return and what breaks without them, and how to keep an inline edit accessible.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    An inline edit inside a Turbo Frame posts fine in the browser, but after a successful save the page does not update and after an invalid save the errors never show. The controller renders with status 200 both times. Explain what Turbo does with those responses and how you would fix the controller.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    You ship inline editing with Turbo Frames: a row becomes a form and returns on save. A screen-reader user reports they cannot tell when a row changed, and a keyboard user loses their place. What is happening, and how do you make the feature accessible?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does Turbo Drive do with a link click?

    It turns the click into a visit: it fetches the page in the background, swaps in the response's body and merges its head, so assets that did not change are never parsed again.

  2. How do advance and replace visits treat the browser's history?

    An advance visit adds a history entry and a replace visit overwrites the current one. Back and Forward restore a page from Turbo's cache.

  3. Which answers does a form submitted through Turbo need, and which answer will Turbo not follow?

    A 303 redirect after a successful save, which Turbo follows with a fresh GET so a reload never resubmits, and 422 when validation fails, which Turbo renders in place. A plain 200 to a non-GET submission is the one answer it will not navigate to.

  4. What do data-turbo="false" and data-turbo-track="reload" do?

    data-turbo="false" hands a link or form back to the browser. data-turbo-track="reload" on a versioned asset forces a full page load when that asset changes. Since Turbo 8, links also prefetch on hover by default.

  5. A link inside a Turbo Frame is clicked. What must the response contain?

    A turbo-frame with the same id; Turbo takes that frame from the response and leaves the rest of the page alone. target="_top" sends the navigation to the whole page, and data-turbo-action="advance" also updates the URL.

  6. When does a frame with src fetch its content, and what does loading="lazy" change?

    Without it the frame loads together with the page. With loading="lazy" it waits until the frame becomes visible, so a panel nobody opens never costs its request.

  7. What does a morphing page refresh keep, and what must you add yourself for inline frame updates?

    Morphing changes only the nodes that differ, and the turbo-refresh-scroll setting preserve keeps the scroll position. A frame swap announces nothing and moves focus only to an autofocus element, so inline updates need focus handling and a polite live region.

Key takeaways

  • Turbo Drive turns a link click or form submission into a visit: it fetches in the background, swaps the response <body> in and merges the <head>, so unchanged assets are never re-parsed.
  • Forms must answer Turbo with a 303 redirect after a successful non-GET and a 422 when validation fails; a plain 200 from a non-GET is the one response Turbo will not navigate to.
  • data-turbo="false" hands a link back to the browser, data-turbo-track="reload" forces a full reload when a versioned asset changes, and links prefetch on hover by default since Turbo 8.
  • A Turbo Frame scopes links and forms to one region; the response must contain a <turbo-frame> with the matching id, and target="_top" or data-turbo-action="advance" change that scope or update the URL.
  • A frame with src eager-loads; adding loading="lazy" defers the fetch until the frame is visible, so a panel that stays hidden never costs its request.
  • A page refresh can morph to update only changed nodes and preserve scroll, but a frame swap announces nothing and moves focus only to an autofocus element, so inline updates need focus management and a polite live region.

Terms in this lesson

morphing
A page-refresh method in which Turbo updates only the DOM nodes that changed, keeping the rest of the page in place, instead of replacing the whole body.
prefetch
Turbo loading a link's page in the background when the pointer rests on it, before the click, so the navigation feels instant; on by default since Turbo 8.
Turbo Drive
The part of Turbo that intercepts link clicks and form submissions, fetches the page in the background, and updates it by swapping the body and merging the head instead of a full reload.
Turbo Frame
A turbo-frame element with a unique id that captures the links and forms inside it, so a response updates only that region of the page and leaves the rest untouched.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.