Testing Turbo Frames and Streams

Chapter 10 made the edit form dynamic with StimulusJS: add and remove nested rows in the browser, then save all at once with Update Recipe. Stimulus is the right tool when it’s about adding dynamic UI to the form.

This chapter is the next UX step using Turbo Frames and Streams. Turbo is useful when you should hit the server and update parts of the page. Turbo Drive (default since Rails 7) already avoids a classic full browser reload. A Turbo frame scopes which region of the response lands (for example the recipe list), so the page parts outside that frame stay put. Turbo Stream removes a card or row in place. That feels smoother than a Turbo Drive visit that replaces the whole page body, especially for deleting a single record or filtering the list.

Until now, destroying a recipe or nested row often redirected or rebuilt the full page body, and the quick filter did the same kind of Turbo Drive visit. You change that with Turbo Frames and Streams in this chapter. On the testing side, the same question as every other chapter still wins: did the outcome land (recipe card gone on destroy, list filtered), not whether a turbo-stream tag showed up?

What you will do in this chapter #

  1. Destroy a recipe from the list with a Turbo Stream, then surf.
  2. Update the system test for destroy the recipe to ensure page is redirected to the list after destroying the recipe, then surf.
  3. Remove an ingredient and a step from the detail page with Turbo Stream, then surf.
  4. Wrap the filter in the recipe list page inside a Turbo Frame so only the list region swaps instead of the full page reload, then surf.
  5. Decide when StimulusJS and Turbo each earn their keep.
  6. Commit after a green run.

Flows you will cover #

These are the outcomes you will prove by the end of the chapter.

Flow You might say Test Type
Destroy from list On index, destroy lentil soup after confirm; card gone; user still on the list page System (TDD)
Remove on show On pancakes show, remove Salt and the preheat step, confirm each; both gone; Flour remains System (TDD)
Nested remove over HTTP DELETE ingredient and step; both counts −1 in the database; show HTML has no Salt and no preheat Integration (after green)
Filter using Turbo Submit quick filter; list shows pancakes, but not lentil soup; Frame scopes the swap to the list System (no TDD; same outcome, only implementation change from full Turbo Drive to Turbo Frame)

Test outcomes, not Turbo markup (implementation) #

A lot of times, you will be tempted to test the implementation details (code syntax or HTML markup) instead of the outcomes. This is a bad habit and you should avoid it. I especially see this tendency when working with Turbo Streams and Frames. A test that only checks Turbo markup can pass while the row never leaves the database, or fail while the user already saw the right result after a redirect.

So, what are the outcomes you should test while avoiding the implementation part?

Flow Assert this (outcome) Avoid this (implementation)
Destroy from the list Recipe title gone from the list; you stayed on index Response body includes <turbo-stream action="remove" target="recipe_xxxx" />
Remove on show Ingredient and Step each decreased by a count of 1 in the database Response body includes <turbo-stream action="remove" target="ingredient_xxxx" /> or <turbo-stream action="remove" target="step_xxxx" />
Nested remove over HTTP Ingredient and Step each decreased by a count of 1 in the database; show HTML has no Salt and no preheat; Flour remains Only that a turbo-stream remove targeted ingredient_…
Filter using Turbo Pancakes title still on filtered index; lentil soup gone assert_select "turbo-frame#recipes" as the main proof

Destroy a recipe from the list #

Destroy for the recipe was added in Chapter 7. It destroyed the recipe and redirected to the list, so the title was gone after you landed there. Now you add Destroy on the index so one card can drop in place with a Turbo Stream, no redirect, smoother UX.

What counts as working? #

Flow You might say
Destroy from list On index, destroy lentil soup after confirm; card gone; user still on the list page

Add scenarios to the test file #

Add the following scenario and the test block to the end of the system test at test/system/recipes_test.rb:

# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: recipes index
  # Action: accept_confirm, Destroy this recipe on lentil soup
  # Expected outcome: still on index; lentil soup title gone; pancakes still visible
  # test "destroys a recipe from the list" do
  # end

Add the system test #

Replace the body of the test "destroys a recipe from the list" with the following:

# test/system/recipes_test.rb
  test "destroys a recipe from the list" do
    recipe = recipes(:lentil_soup)
    visit recipes_url

    assert_text recipe.title
    assert_text recipes(:pancakes).title

    accept_confirm do
      within("##{dom_id(recipe)}") { click_on "Destroy this recipe" }
    end

    assert_current_path recipes_path
    assert_no_text recipe.title
    assert_text recipes(:pancakes).title
  end

This is what’s happening in the code above:

  • within("##{dom_id(recipe)}") scopes the click to a particular recipe card so you do not hit Destroy on the wrong recipe.
  • assert_current_path recipes_path proves you stayed on the same recipe list page without any redirect.
  • assert_no_text / assert_text are the outcome to ensure the lentil soup recipe is gone from the list while the pancakes recipe is still visible. No check for a <turbo-stream> tag (testing outcomes, not implementation).

Run:

bin/rails test test/system/recipes_test.rb -i test_destroys_a_recipe_from_the_list

You should see an error. Index does not have Destroy this recipe inside each card yet. That is red.

E

Error:
RecipesTest#test_destroys_a_recipe_from_the_list:
Capybara::ElementNotFound: Unable to find link or button "Destroy this recipe" within #<Capybara::Node::Element tag="div" path="/HTML/BODY[1]/DIV[1]/DIV[1]">

Green: Add destroy button and stream the response from the controller #

Scaffold often puts id="<%= dom_id recipe %>" on the recipe partial, while leaving the Show this recipe link in the index file outside that div. dom_id is required for the Turbo Stream to target the correct recipe card and if we leave the show button as it is then when the recipe card is destroyed, the show button will still be visible on the index page; not what we want.

Let’s fix this by putting the whole list card in the partial and also add a destroy button to the recipe list page. The recipe partial is also used on show, so we will also pass a for_show local to hide the Show this recipe link on the detail page.

Replace the _recipe.html.erb partial with the following:

<%# app/views/recipes/_recipe.html.erb %>
<% for_show = local_assigns.fetch(:for_show, false) %>
<% destroy_url = for_show ? recipe_path(recipe, format: :html) : recipe %>

<div id="<%= dom_id recipe %>">
  <div>
    <strong>Title:</strong>
    <%= recipe.title %>
  </div>

  <div>
    <strong>Description:</strong>
    <%= recipe.description %>
  </div>

  <div>
    <strong>Prep time:</strong>
    <%= recipe.prep_time %>
  </div>

  <div>
    <strong>Servings:</strong>
    <%= recipe.servings %>
  </div>

  <% unless for_show %>
    <p>
      <%= link_to "Show this recipe", recipe %>
    </p>
  <% end %>

  <%= button_to "Destroy this recipe", destroy_url, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
</div>

This is what’s happening in the code above:

  • for_show = local_assigns.fetch(:for_show, false) reads an optional local. Index calls render recipe with no local, so for_show is false. Show will pass for_show: true.
  • destroy_url picks the path for Destroy. On show (for_show: true) it is recipe_path(recipe, format: :html), so the request hits format.html and redirects to the list. On the list it is plain recipe, so Turbo can ask for a Stream and get turbo_stream.remove.
  • unless for_show wraps Show this recipe link so on the list you get the link. On show you hide the link since you are already on the detail page.
  • dom_id recipe on the wrapper is what turbo_stream.remove targets when you destroy from the list.

Search render @recipe in the show page and update it to use the for_show local while keeping the rest of the code as it is:

<%# app/views/recipes/show.html.erb %>

<%# ... old code ... %>

<%= render @recipe, for_show: true %>

<%# ... old code ... %>

Slim the index view to only render the partial (keeping your filter form and New recipe link as they are):

<%# app/views/recipes/index.html.erb %>

<%# ... old code ... %>

<div id="recipes">
  <% @recipes.each do |recipe| %>
    <%= render recipe %>
  <% end %>
</div>

<%# ... old code ... %>

Next, update the recipes controller so destroy action can answer with a Turbo Stream remove. That drops the card in place on the list instead of redirecting through format.html.

Finally, replace the destroy action with the following code at app/controllers/recipes_controller.rb:

# app/controllers/recipes_controller.rb
def destroy
  @recipe.destroy!

  respond_to do |format|
    format.turbo_stream { render turbo_stream: turbo_stream.remove(@recipe) }
    format.html do
      redirect_to recipes_path,
                  notice: "Recipe was successfully destroyed.",
                  status: :see_other
    end
    format.json { head :no_content }
  end
end

Only format.turbo_stream { render turbo_stream: turbo_stream.remove(@recipe) } is new code here:

  • format.turbo_stream tells the server to respond with a Turbo Stream for destroying the recipe.
  • render turbo_stream renders the Turbo code back to the browser.
  • turbo_stream.remove(@recipe) targets dom_id(@recipe) (for example recipe_12) from the recipe partial and only removes that particular recipe card from the list.

You might be wondering why we still have the format.html when the list uses Turbo Streams. It’s because HTML clients and destroy from the show page in the next section still get the redirect to the list.

Re-run the list destroy test:

bin/rails test test/system/recipes_test.rb -i test_destroys_a_recipe_from_the_list

You want 0 failures and 0 errors. That is green.

Next, quickly surf the delete feature in the browser:

  1. Start bin/dev if needed.
  2. Open the recipes index.
  3. On Lentil soup, click Destroy this recipe, confirm, and check the whole card disappears (title, Show, and Destroy), while you stay on the list (no page redirect or reload). The filter and New recipe should also stay in the page.
  4. (Optional) Refresh the page to ensure the changes persisted: the lentil soup recipe is still gone from the list while pancakes recipe remains.

Destroy a recipe from show page #

Turbo Stream is a poor fit for destroy from show page because after the recipe is deleted, that show URL has nothing left to display. Instead, you want to land on the list after the delete operation.

Remove the extra Destroy on show #

You just saw two Destroy this recipe buttons on pancakes show at app/views/recipes/show.html.erb. Remove the one from the show template so only the partial’s Destroy remains.

Search app/views/recipes/show.html.erb for the destroy button and remove it:

<%= button_to "Destroy this recipe", @recipe, method: :delete, data: { turbo_confirm: "Are you sure?" } %>

Update the system test for the redirect #

“Destroy from the list” and “Destroy from show page” have one subtle difference: list destroy stays on the list with a Turbo Stream remove, while show destroy uses HTML format and redirects to the list.

Right now in the system test for “destroys a recipe”, you are only asserting that the recipe is removed from the list. To prove the user was redirected to the recipe list, add an assertion on the current path.

Add scenarios to the test file #

Replace the scenario comment for the test destroys a recipe at test/system/recipes_test.rb with the following:

# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: lentil soup show
  # Action: accept_confirm, Destroy this recipe
  # Expected outcome: redirected to the recipes list; lentil soup title gone
  # test "destroys a recipe" do
  # end

Update the system test #

Replace the body of the test destroys a recipe with the following to assert the current path is the recipes list page:

# test/system/recipes_test.rb
  test "destroys a recipe" do
    recipe = recipes(:lentil_soup)
    visit recipe_url(recipe)

    accept_confirm do
      click_on "Destroy this recipe"
    end

    assert_current_path recipes_path
    assert_no_text recipe.title
  end

This is what’s happening in the code above:

  • assert_current_path recipes_path proves destroy redirected to the list (not Stream remove that leaves you on a deleted show URL).
  • assert_no_text recipe.title is the same list outcome as Chapter 7, without a second visit.

Run the system test, you want 0 failures and 0 errors.

bin/rails test test/system/recipes_test.rb -i test_destroys_a_recipe

Next, quickly surf in the browser:

  1. Open Fluffy pancakes.
  2. Click Destroy this recipe, confirm, and check you land on the recipes list without that title.
  3. Confirm the delete operation still works on the list page as well by destroying another recipe and ensuring the title is gone from the list while you stay on the list page.

Remove ingredients and steps from show #

With the changes from Chapter 10, you are able to remove ingredients and steps using the nested fields from the recipe form. In this section, you will add Remove buttons to the show page and test the ability to remove an ingredient or a step with one click.

New Remove buttons and nested destroy routes are custom code we will write by hand, so you will start with a system test first (red), and then wire the button in the show page (green).

What counts as working? #

Flow You might say
Remove on show On pancakes show, remove Salt and the preheat step, both gone; Flour remains
Nested remove over HTTP DELETE ingredient and step; both counts −1; show HTML has no Salt and no preheat

Add scenarios to the test file #

Add the following scenario to the system test file at test/system/recipes_test.rb:

# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: pancakes show
  # Action: accept_confirm, Remove on Salt, then Remove on preheat
  # Expected outcome: Salt and preheat gone from page; Flour still visible
  # test "removes ingredients and steps from the detail page" do
  # end

Add the system test #

Replace the body of the test removes ingredients and steps from the detail page with the following code:

# test/system/recipes_test.rb
  test "removes ingredients and steps from the detail page" do
    recipe = recipes(:pancakes)
    visit recipe_url(recipe)

    assert_text "Salt"
    assert_text "Flour"
    assert_text steps(:preheat).instruction

    accept_confirm do
      within("##{dom_id(ingredients(:salt))}") { click_on "Remove" }
    end

    accept_confirm do
      within("##{dom_id(steps(:preheat))}") { click_on "Remove" }
    end

    assert_no_text "Salt"
    assert_text "Flour"
    assert_no_text steps(:preheat).instruction
  end

This is what’s happening in the code above:

  • You first start with asserting that the Salt and Flour ingredients are present on the show page.
  • You then delete the Salt and the preheat step one by one by clicking the Remove button and confirming the dialog using accept_confirm.
  • within scopes each click to the Salt row or the preheat step so you do not remove Flour (or the wrong step) by accident. dom_id helps you target the correct element.
  • assert_no_text / assert_text verifies the outcome: Salt and the preheat step are gone, but Flour remains.

Run:

bin/rails test test/system/recipes_test.rb -i test_removes_ingredients_and_steps_from_the_detail_page

You should see an error. Show has no Remove buttons (and no nested destroy routes) yet. That is red.

E

Error:
RecipesTest#test_removes_ingredients_and_steps_from_the_detail_page:
Capybara::ElementNotFound: Unable to find css "#ingredient_xxxxx"

Green: nested destroy routes, controllers, and Remove on show #

We want to destroy ingredients and steps one by one from the detail page, this functionality is not possible right now with destroy action we have in the recipe controller. We will start first by adding a route so each ingredient and step can be destroyed individually from their own routes and controllers.

You might be asking “Why not dump everything into the recipe controller?” and the answer is that we want to keep the recipe controller focused on the recipe itself and not worry about the ingredients and steps. This also makes controller actions stick to RESTful principles and in return we get a cleaner testable code.

Add two new routes, one each for destroying ingredients and steps under recipes at the end of config/routes.rb file:

# config/routes.rb
resources :recipes do
  resources :ingredients, only: :destroy
  resources :steps, only: :destroy
end

Next, we need to generate the controllers for the new routes. Start by generating and opening the ingredients controller in the bash terminal with the following command:

nano app/controllers/ingredients_controller.rb

This will create a new file at app/controllers/ingredients_controller.rb, then add the following code:

# app/controllers/ingredients_controller.rb
class IngredientsController < ApplicationController
  before_action :set_recipe, :set_ingredient

  def destroy
    @ingredient.destroy!
    render turbo_stream: turbo_stream.remove(@ingredient)
  end

  private

  def set_recipe
    @recipe = Recipe.find(params[:recipe_id])
  end

  def set_ingredient
    @ingredient = @recipe.ingredients.find(params[:id])
  end
end

Do the same for the steps controller, generate and open the file with the following command in the bash terminal:

nano app/controllers/steps_controller.rb

Then add the following code to the file:

# app/controllers/steps_controller.rb
class StepsController < ApplicationController
  before_action :set_recipe, :set_step

  def destroy
    @step.destroy!
    render turbo_stream: turbo_stream.remove(@step)
  end

  private

  def set_recipe
    @recipe = Recipe.find(params[:recipe_id])
  end

  def set_step
    @step = @recipe.steps.find(params[:id])
  end
end

This is what’s happening in the controllers code above (summarizing both steps and ingredients controllers):

  • Both controllers share the same shape: find the parent recipe, find the child through that recipe, destroy it, then answer with a Turbo Stream remove.
  • before_action :set_recipe loads @recipe from params[:recipe_id] (the nested route). set_ingredient / set_step then find the child with @recipe.ingredients.find or @recipe.steps.find, so you cannot delete another recipe’s row by id alone.
  • destroy! raises if the delete fails. On success, render turbo_stream: turbo_stream.remove(...) sends a remove stream targeting dom_id for that ingredient or step.
  • No format.html here. Remove only runs from the recipe show page through Turbo, so a Stream response is enough.

Finally, wire Destroy buttons in the show page and call these new destroy URLs you added above in routes and controllers.

Replace the code for “Ingredients” and “Steps” lists with the following while keeping everything else unchanged at app/views/recipes/show.html.erb file:

<% if @recipe.ingredients.any? %>
  <h2>Ingredients</h2>
  <ul id="ingredients">
    <% @recipe.ingredients.each do |ingredient| %>
      <li id="<%= dom_id(ingredient) %>">
        <%= ingredient.name %><% if ingredient.quantity.present? %> (<%= number_with_precision(ingredient.quantity, precision: 2, strip_insignificant_zeros: true) %><% if ingredient.unit.present? %> <%= ingredient.unit %><% end %>)<% end %>
        <%= button_to "Remove",
              recipe_ingredient_path(@recipe, ingredient),
              method: :delete,
              data: { turbo_confirm: "Remove this ingredient?" } %>
      </li>
    <% end %>
  </ul>
<% end %>

<% if @recipe.steps.any? %>
  <h2>Steps</h2>
  <ol id="steps">
    <% @recipe.steps.order(:position).each do |step| %>
      <li id="<%= dom_id(step) %>">
        <%= step.instruction %>
        <%= button_to "Remove",
              recipe_step_path(@recipe, step),
              method: :delete,
              data: { turbo_confirm: "Remove this step?" } %>
      </li>
    <% end %>
  </ol>
<% end %>

This is what’s happening in the code above:

  • Each ingredient and step row gets id from dom_id(...), matching what turbo_stream.remove targets in the controllers.
  • Remove posts to the nested destroy routes (recipe_ingredient_path / recipe_step_path) with turbo_confirm, same confirm pattern as recipe Destroy.

Re-run the system test:

bin/rails test test/system/recipes_test.rb -i test_removes_ingredients_and_steps_from_the_detail_page

You want 0 failures and 0 errors. That is green.

Next, quickly surf in the browser and confirm the functionality:

  1. Open Fluffy pancakes.
  2. Click Remove next to Salt, confirm, and check Salt disappears without a full navigation.
  3. Click Remove next to Preheat the pan., confirm, and check that step disappears too.
  4. Refresh: both stay gone; Flour remains.

Prove nested removal over HTTP (Integration test) #

The system smoke already proved Remove in the browser. This integration test skips Capybara and hits the new nested destroy path over HTTP: delete Salt and the preheat step, then check counts decreased by 1 and check that the show page has no Salt and no preheat. You will not use TDD for this slice since the routes and controllers just landed in Green section above.

Add scenarios to the test file #

Add the following scenario to the integration test file at test/integration/recipes_integration_test.rb:

# test/integration/recipes_integration_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: pancakes has salt, flour, and preheat
  # Action: delete salt and preheat via nested URLs
  # Expected outcome: Ingredient.count and Step.count each -1; show has no Salt and no preheat; Flour remains
  # test "removes ingredients and steps from the detail page" do
  # end

Add the integration test #

Replace the body of the test removes ingredients and steps from the detail page with the following code:

# test/integration/recipes_integration_test.rb
  test "removes ingredients and steps from the detail page" do
    recipe = recipes(:pancakes)
    salt = ingredients(:salt)
    preheat = steps(:preheat)

    assert_difference ["Ingredient.count", "Step.count"], -1 do
      delete recipe_ingredient_url(recipe, salt), as: :turbo_stream
      delete recipe_step_url(recipe, preheat), as: :turbo_stream
    end

    get recipe_url(recipe)
    assert_response :success
    assert_no_match salt.name, response.body
    assert_no_match preheat.instruction, response.body
    assert_match "Flour (2 cups)", response.body
  end

This is what’s happening in the code above:

  • Two delete calls hit the nested destroy actions as Turbo Stream requests, matching how the show page Remove buttons talk to the server.
  • assert_difference ["Ingredient.count", "Step.count"], -1 expects one fewer of each record after performing the deletes.
  • Integration tests don’t have ability to test Stream DOM updates, so you fetch the show page and check that Salt and the preheat instruction are gone and Flour (2 cups) remains.

Run the test, you want 0 failures and 0 errors:

bin/rails test test/integration/recipes_integration_test.rb -i test_removes_ingredients_and_steps_from_the_detail_page

Filter recipes with a Turbo Frame #

Chapter 8 already proved ?quick=1 filters the list over HTTP. In this section you add a system smoke for the same title outcome, then wrap the list in a Turbo Frame so the filter response swaps only that region. Turbo Drive (default since Rails 7) already avoids a classic full browser reload; the Turbo Frame’s job is scoping which part of the response lands on the page.

What counts as working? #

Flow You might say
Filter recipes with a Turbo Frame Submit quick filter; list shows pancakes, not lentil soup

Add scenarios to the test file #

Add the following scenario to the system test file at test/system/recipes_test.rb:

# test/system/recipes_test.rb
  # Actor: guest (until Chapter 12)
  # Starting point: recipes index with pancakes and lentil soup
  # Action: select Quick, click Apply filter
  # Expected outcome: pancakes visible; lentil soup gone from the list
  # test "filters the list" do
  # end

Add the system test #

Replace the body of the test filters the list with the following code:

# test/system/recipes_test.rb
  test "filters the list" do
    visit recipes_url
    assert_text recipes(:pancakes).title
    assert_text recipes(:lentil_soup).title

    select "Quick recipes (under 30 min)", from: "Show"
    click_on "Apply filter"

    assert_text recipes(:pancakes).title
    assert_no_text recipes(:lentil_soup).title
  end

This is what’s happening in the code above:

  • Both fixture titles are on the list before you filter.
  • After the dropdown change, Apply filter submits and the asserts check titles only (pancakes stays, lentil soup gone), not a turbo-frame tag.
  • Green before any Frame exists is expected. Chapter 8’s Drive-backed filter already shows the right titles.

Run:

bin/rails test test/system/recipes_test.rb -i test_filters_the_list

You will likely get 0 failures before any Turbo Frame exists for the filter. That is expected, not a bug in the test.

Chapter 8’s filter form already does a full page GET backed by Turbo Drive, it then shows the correct list of recipes. The system test you just added above asserts that outcome (assert_text / assert_no_text on recipe titles), instead of an implementation detail: a turbo-frame tag. So the test cannot go red just because the Turbo Frame for the filter is missing. The Turbo Frame is a UX upgrade you add on purpose: narrower swap than a Turbo Drive visit that replaces the page body.

Wrap the list in a Turbo Frame #

Replace the whole content of the app/views/recipes/index.html.erb file with the following code so the filter targets a frame and the recipe list renders inside that frame:

<%# app/views/recipes/index.html.erb %>
<p style="color: green"><%= notice %></p>

<% content_for :title, "Recipes" %>

<h1>Recipes</h1>

<%= form_with url: recipes_path, method: :get, class: "recipe-filters",
      data: { turbo_frame: "recipes" } do %>
  <%= label_tag :quick, "Show" %>
  <%= select_tag :quick,
        options_for_select(
          [["All recipes", ""], ["Quick recipes (under 30 min)", "1"]],
          params[:quick]
        ),
        id: "quick" %>
  <%= submit_tag "Apply filter" %>
<% end %>

<%= turbo_frame_tag "recipes" do %>
  <% @recipes.each do |recipe| %>
    <%= render recipe %>
  <% end %>
<% end %>

<%= link_to "New recipe", new_recipe_path %>

This is what’s happening in the code above:

  • data: { turbo_frame: "recipes" } tells Turbo to put the form response into the frame named recipes instead of Turbo Drive replacing the whole page body. Only the matching recipes frame swaps.
  • turbo_frame_tag "recipes" wraps the list of recipes. The filtered collection re-renders inside that frame. The <h1> and New recipe link sit outside the frame, so Turbo leaves those live DOM nodes alone and only swaps the framed list.

Re-run the filter system test to ensure the Turbo Frame did not break the test outcome. You want 0 failures and 0 errors.

bin/rails test test/system/recipes_test.rb -i test_filters_the_list

You could drive this same filter from RecipesController#index with a Turbo Stream instead of a Frame. Sketch only (do not add this to your app):

# app/controllers/recipes_controller.rb (index)
respond_to do |format|
  format.html
  format.turbo_stream
end
<%# app/views/recipes/index.turbo_stream.erb %>
<%= turbo_stream.update "recipes" do %>
  <% @recipes.each do |recipe| %>
    <%= render recipe %>
  <% end %>
<% end %>

That works, but you need format handling plus a stream template, and the form must request a Stream. For “refresh one region after a GET,” a Turbo Frame just works: point the form at the frame, wrap the list, and just leave the controller alone.

Next, quickly surf in the browser:

  1. Open the recipes index.
  2. Filter to Quick recipes (under 30 min) and click Apply filter.
  3. Confirm pancakes stays and lentil soup leaves the list.

Stream removes and a framed quick filter now prove outcomes, not Turbo markup.

Mid-page deletes and a scoped filter with tests behind them is a real milestone.

You asserted cards and rows gone, and a filtered list, without chasing turbo-stream tags. If that habit is sticking, fund the next chapter and help keep the guide free.

One-time support via Stripe. No account required.

When Stimulus or Turbo earns its keep #

You have now used Stimulus (Chapter 10), Turbo Streams (destroy and nested remove), and a Turbo Frame (quick filter). All three make the page feel dynamic but that does not mean Turbo should handle every dynamic bit.

Turbo is a strong tool for “the server answered, now update part of the page.” If you reach for it every time something moves in the browser, you are using a hammer for every job. Pick the tool that matches the job.

Tool Job it fits Cookbook example
Stimulus Browser-only behavior before (or without) a new server round trip. Clone fields, hide a row, toggle UI. Add/remove nested rows on edit, then submit the whole form once (Chapter 10)
Turbo Frame One request should refresh one region of the page (nav and other parts of the page stay put). Quick filter swaps the recipe list inside the frame
Turbo Stream One request should change specific DOM pieces (remove a row, prepend a flash, replace a card). Destroy a recipe card on index; remove an ingredient or step on show

Turbo Frame and Stream are normally used together and mostly you will not need to choose between them.

A quick gut check #

Ask yourself:

  1. Does the change need the server right now? No → start with Stimulus (or plain HTML). Yes → Turbo Frame or Stream.
  2. Are you still filling one form that will submit later? Prefer Stimulus for row add/remove.
  3. Should one region of the page refresh after navigate or submit? Prefer a Turbo Frame.
  4. Should a precise piece of DOM change after create, update, or destroy? Prefer a Turbo Stream.

You will still mix tools in a real app. Stimulus can live inside a Frame. A Stream can land after a Frame request. The mistake is treating “dynamic” as “must be Turbo” every time.

Commit your work #

Run the full suite:

bin/rails test:all

You want 0 failures and 0 errors across model, integration, and system tests. Then:

git add .
git commit -m "Add Turbo Stream removes and framed quick filter"

Small commits make it easier to roll back, bisect a regression, or pick up on another machine.

What is next #

You now have Stimulus for nested form rows and Turbo for mid-page deletes and framed filtering. Chapter 12 ends guest write access: sign-in, guest read-only list and show, and session helpers on the same integration habit. Come back to create, update, and destroy (including these Stream removes) once only signed-in users can change recipes.

Continue to Testing authentication.

Keep Minitest Rails independent

Minitest Rails is an independent educational guide for Rails developers learning automated testing.

Reader support funds new chapters, Rails version updates, and more real-world examples.

Disclaimer: This guide is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.