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 #
- Destroy a recipe from the list with a Turbo Stream, then surf.
- Update the system test for
destroy the recipeto ensure page is redirected to the list after destroying the recipe, then surf. - Remove an ingredient and a step from the detail page with Turbo Stream, then surf.
- 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.
- Decide when StimulusJS and Turbo each earn their keep.
- 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_pathproves you stayed on the same recipe list page without any redirect.assert_no_text/assert_textare 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 callsrender recipewith no local, sofor_showisfalse. Show will passfor_show: true.destroy_urlpicks the path for Destroy. On show (for_show: true) it isrecipe_path(recipe, format: :html), so the request hitsformat.htmland redirects to the list. On the list it is plainrecipe, so Turbo can ask for a Stream and getturbo_stream.remove.unless for_showwraps 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 recipeon the wrapper is whatturbo_stream.removetargets 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_streamtells the server to respond with a Turbo Stream for destroying the recipe.render turbo_streamrenders the Turbo code back to the browser.turbo_stream.remove(@recipe)targetsdom_id(@recipe)(for examplerecipe_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:
- Start
bin/devif needed. - Open the recipes index.
- 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.
- (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_pathproves destroy redirected to the list (not Stream remove that leaves you on a deleted show URL).assert_no_text recipe.titleis the same list outcome as Chapter 7, without a secondvisit.
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:
- Open Fluffy pancakes.
- Click Destroy this recipe, confirm, and check you land on the recipes list without that title.
- 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. withinscopes each click to the Salt row or the preheat step so you do not remove Flour (or the wrong step) by accident.dom_idhelps you target the correct element.assert_no_text/assert_textverifies 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_recipeloads@recipefromparams[:recipe_id](the nested route).set_ingredient/set_stepthen find the child with@recipe.ingredients.findor@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 targetingdom_idfor that ingredient or step.- No
format.htmlhere. 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
idfromdom_id(...), matching whatturbo_stream.removetargets in the controllers. - Remove posts to the nested destroy routes (
recipe_ingredient_path/recipe_step_path) withturbo_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:
- Open Fluffy pancakes.
- Click Remove next to Salt, confirm, and check Salt disappears without a full navigation.
- Click Remove next to Preheat the pan., confirm, and check that step disappears too.
- 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
deletecalls 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"], -1expects 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-frametag. - 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 namedrecipesinstead of Turbo Drive replacing the whole page body. Only the matchingrecipesframe 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:
- Open the recipes index.
- Filter to Quick recipes (under 30 min) and click Apply filter.
- 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:
- Does the change need the server right now? No → start with Stimulus (or plain HTML). Yes → Turbo Frame or Stream.
- Are you still filling one form that will submit later? Prefer Stimulus for row add/remove.
- Should one region of the page refresh after navigate or submit? Prefer a Turbo Frame.
- 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.