<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>The Hotwire Club</title>
    <description></description>
    <link>https://hotwire.club/</link>
    <atom:link href="https://hotwire.club/feed.xml" rel="self" type="application/rss+xml"/>
    <pubDate>Wed, 22 Jul 2026 14:53:40 +0000</pubDate>
    <lastBuildDate>Wed, 22 Jul 2026 14:53:40 +0000</lastBuildDate>
    <generator>Jekyll v4.3.3</generator>
    
      <item>
        <title>Stimulus - Promise-Returning Outlet Methods</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;Stimulus controllers usually talk to each other via dispatched events. That works great for fire-and-forget notifications, but events have no return channel. Sometimes you want a &lt;em&gt;request/response&lt;/em&gt; shape instead: ask a controller a question, wait for the answer, act on it.&lt;/p&gt;

&lt;p&gt;Enter the deferred-promise pattern. The trick is simple: create a promise, stash its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolve&lt;/code&gt; function on the controller instance, and settle it later from a different event handler. Wrap that in a public method and you’ve turned a Stimulus controller into an await-able mini-API. Combine it with outlets (&lt;a href=&quot;../2026-05-26-stimulus-optimistic-toast-auto-save/&quot;&gt;Challenge 51&lt;/a&gt; used them for the toast controller) and any other controller can call it like a library function.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We have a list of items, each with a delete button. Deletion requires confirmation. A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;-based &lt;a href=&quot;https://stackblitz.com/edit/stimulus-promise-returning-outlet-methods?file=controllers%2Fconfirm_controller.js&quot;&gt;confirm controller&lt;/a&gt; already exists with Yes / No buttons, and a &lt;a href=&quot;https://stackblitz.com/edit/stimulus-promise-returning-outlet-methods?file=controllers%2Flist_item_controller.js&quot;&gt;list-item controller&lt;/a&gt; handles the delete action.&lt;/p&gt;

&lt;p&gt;But right now the two talk via a custom event: the list-item controller dispatches a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confirm:ask&lt;/code&gt; event, and then has to listen for a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confirm:answer&lt;/code&gt; event to find out what the user clicked. The delete logic is split across two handlers (before-ask and after-answer), with state smeared in between to remember &lt;em&gt;which&lt;/em&gt; item was pending deletion.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://stackblitz.com/edit/stimulus-promise-returning-outlet-methods?file=index.html&quot;&gt;HTML&lt;/a&gt; is fully wired: the list items live statically in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.html&lt;/code&gt;, so you can see every data attribute in one place, including the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-list-item-confirm-outlet&lt;/code&gt; attribute you’ll need later. The &lt;a href=&quot;https://stackblitz.com/edit/stimulus-promise-returning-outlet-methods?file=index.js&quot;&gt;server&lt;/a&gt; handles the actual deletion via POST-redirect-GET.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;Rewrite the confirm controller to expose a single public method:&lt;/p&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;ask&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;cm&quot;&gt;/* returns Promise&amp;lt;boolean&amp;gt; */&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ol&gt;
  &lt;li&gt;Inside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ask()&lt;/code&gt;, set the dialog text, show the dialog, and return a promise whose &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolve&lt;/code&gt; is stored on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;In the Yes / No action handlers, call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.resolve(true)&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.resolve(false)&lt;/code&gt; and close the dialog.&lt;/li&gt;
  &lt;li&gt;In the list-item controller, declare &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;confirm&lt;/code&gt; as an outlet and collapse the two-handler dance into one linear method:&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-js highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;preventDefault&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;confirmOutlet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;ask&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Delete this item?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;closest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;form&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;requestSubmit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/stimulus-promise-returning-outlet-methods?file=controllers%2Fconfirm_controller.js&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s the target behavior: clicking a delete button opens the dialog, “Yes” removes the item, “No” leaves it alone, and the list-item controller reads top to bottom like synchronous code.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260722_Stimulus_Promise_Returning_Outlet_Methods.gif&quot; alt=&quot;List of items where clicking delete opens a confirmation dialog; confirming removes the item, declining keeps it&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder:&lt;/strong&gt; the Node server simulates a Rails backend. If you make changes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.js&lt;/code&gt;, restart with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm start&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;

&lt;p&gt;What should happen when the user dismisses the dialog with Escape, or by clicking the backdrop? Right now, nothing resolves the promise, so the caller &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;await&lt;/code&gt;s forever. Wire those escape hatches up to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolve(false)&lt;/code&gt; so callers never hang. Hint: the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element fires a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;close&lt;/code&gt; event that covers Escape for free.&lt;/p&gt;

&lt;h2 id=&quot;caveat&quot;&gt;Caveat&lt;/h2&gt;

&lt;p&gt;If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ask()&lt;/code&gt; is called while a previous call is still pending, you’ll overwrite &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.resolve&lt;/code&gt; and strand the first caller’s promise, which then never settles. Depending on your UX you can queue calls, reject the previous promise, or simply guard against concurrent asks by refusing (or ignoring) a second call while the dialog is open. Whatever you pick, make it deliberate: a promise that silently never resolves is one of the nastiest bugs to track down.&lt;/p&gt;
</description>
        <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-07-22-stimulus-promise-returning-outlet-methods/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-07-22-stimulus-promise-returning-outlet-methods/</guid>
        
        <category>Outlets</category>
        
        <category>Deferred Promises</category>
        
        
        <category>Stimulus</category>
        
      </item>
    
      <item>
        <title>Stimulus - Optimistic Toast Notifications with Auto-Save</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;Here’s a pattern you’ll find in any app with inline-editable fields: change a value, auto-save it, show a little toast that confirms the save. It feels polished when it works. The problem is where that toast comes from.&lt;/p&gt;

&lt;p&gt;The standard Hotwire approach is to let the server send the toast back as a Turbo Stream, something like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo_stream.append &quot;toast&quot;&lt;/code&gt;. That works, but the feedback is tied to the round trip latency. If the server takes 800ms to respond (database write, background job, whatever), the user stares at a silent form for 800ms wondering if anything happened. On a slow connection it’s even worse.&lt;/p&gt;

&lt;p&gt;In &lt;a href=&quot;../2024-03-26-optimistic-ui-with-turbo-8-morphs/&quot;&gt;Challenge 21&lt;/a&gt; we explored optimistic UI at the DOM level, swapping a favorite button’s state instantly via an inline &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; template and reconciling later with a Turbo 8 morph. This time the goal is different: we’re not morphing HTML, we’re firing a side-channel notification (a toast) the instant the request leaves the browser, before we have a response. If the server comes back with an error, a second toast follows up with the bad news. Instant feedback in the happy path, honest feedback in the sad path. No radio silence.&lt;/p&gt;

&lt;p&gt;There are two twists here. First, instead of one monolithic controller we’ll build two small ones, an auto-save controller and a toast controller, and link them through a Stimulus outlet. The auto-save controller can call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.toastOutlet.show(...)&lt;/code&gt; without knowing anything about the toast’s internals. Second, the toast controller wraps &lt;a href=&quot;https://github.com/caroso1222/notyf&quot;&gt;Notyf&lt;/a&gt;, a tiny (~3 KB) notification library. Wrapping a third-party lib in a Stimulus controller is a pattern you’ll reach for often: the controller owns the lifecycle, the outlet exposes a clean API, and the rest of your app never imports the library directly.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We have a simple settings form with a few fields. The &lt;a href=&quot;https://stackblitz.com/edit/stimulus-optimistic-toast-auto-save?file=index.js&quot;&gt;server&lt;/a&gt; introduces an artificial delay (800-1500ms) to simulate a real-world round trip, and returns a 422 error roughly 20% of the time so you can test the failure path.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://stackblitz.com/edit/stimulus-optimistic-toast-auto-save?file=index.html&quot;&gt;HTML&lt;/a&gt; is fully wired: each input has a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-action=&quot;change-&amp;gt;auto-save#save&quot;&lt;/code&gt; attribute, the form declares a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-auto-save-toast-outlet&lt;/code&gt; pointing at the toast container, and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;div id=&quot;toast-container&quot; data-controller=&quot;toast&quot;&amp;gt;&lt;/code&gt; sits at the bottom of the page. &lt;a href=&quot;https://github.com/caroso1222/notyf&quot;&gt;Notyf&lt;/a&gt; is already in the &lt;a href=&quot;https://stackblitz.com/edit/stimulus-optimistic-toast-auto-save?file=index.html%3AL13&quot;&gt;import map&lt;/a&gt; and its CSS is loaded via a CDN link tag.&lt;/p&gt;

&lt;p&gt;Both controllers are &lt;a href=&quot;https://stackblitz.com/edit/stimulus-optimistic-toast-auto-save?file=controllers%2Findex.js&quot;&gt;registered&lt;/a&gt; but their methods are empty. The outlet declaration and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-action&lt;/code&gt; bindings are in place; you just need to fill in the logic.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;Fill in the two empty controller methods:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://stackblitz.com/edit/stimulus-optimistic-toast-auto-save?file=controllers%2Ftoast_controller.js&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;toast_controller#show(message, variant)&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; - Notyf is already instantiated in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;connect()&lt;/code&gt;. Implement &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;show(message, variant)&lt;/code&gt; so it calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.notyf.success(message)&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.notyf.error(message)&lt;/code&gt; depending on the variant. Hint: Notyf uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;error&lt;/code&gt; where our API says &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;danger&lt;/code&gt;, so you’ll need to map the name.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://stackblitz.com/edit/stimulus-optimistic-toast-auto-save?file=controllers%2Fauto_save_controller.js&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;auto_save_controller#save()&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; - Build a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FormData&lt;/code&gt; from the form, call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.toastOutlet.show(&quot;Saved!&quot;, &quot;success&quot;)&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fetch&lt;/code&gt;, then send the request. If the response comes back with a non-ok status, fire a second toast: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;this.toastOutlet.show(&quot;Save failed&quot;, &quot;danger&quot;)&lt;/code&gt;. The outlet wiring (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;static outlets = [&quot;toast&quot;]&lt;/code&gt; and the HTML attribute) is already in place.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order matters: the optimistic toast fires the instant the request dispatches, not when the response arrives.&lt;/p&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/stimulus-optimistic-toast-auto-save?file=index.html&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s the target behavior: the moment you change a field, a green “Saved!” toast slides in immediately. If the server returns an error (the demo server fails randomly ~20% of the time), a red “Save failed” toast follows a beat later.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260526_Stimulus_Optimistic_Toast_Auto_Save.gif&quot; alt=&quot;Settings form where changing a field immediately shows a green Saved toast, and a red error toast appears only if the server responds with failure&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder:&lt;/strong&gt; the Node server simulates a Rails backend. If you make changes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.js&lt;/code&gt;, restart with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm start&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;

&lt;p&gt;Two directions you could take this further. First, make the messages configurable per form via Stimulus values: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-auto-save-success-message-value=&quot;Profile updated&quot;&lt;/code&gt; overrides the default “Saved!” text, so the same controller serves every auto-saving form without changes. Second, try replacing Notyf with a hand-rolled toast built from a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; and CSS keyframes. The controller’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;show()&lt;/code&gt; signature stays the same; only the internals change. That’s the benefit of wrapping the library in a controller: swapping implementations is invisible to every outlet consumer.&lt;/p&gt;

&lt;h2 id=&quot;caveat&quot;&gt;Caveat&lt;/h2&gt;

&lt;p&gt;Watch the debounce timing. If the user tabs through three fields in quick succession, you don’t want three overlapping requests and three “Saved!” toasts stacking up. A debounce of 300-500ms collapses rapid edits into a single request. But the toast should fire on the leading edge, when the request actually dispatches, not on the trailing edge of the debounce timer. Otherwise you lose the instant feeling that makes optimistic UI worth doing in the first place.&lt;/p&gt;
</description>
        <pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-05-26-stimulus-optimistic-toast-auto-save/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-05-26-stimulus-optimistic-toast-auto-save/</guid>
        
        <category>Outlets</category>
        
        <category>Optimistic UI</category>
        
        
        <category>Stimulus</category>
        
      </item>
    
      <item>
        <title>Turbo Drive - Shared Element View Transitions</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;This is the third installment in our View Transitions series. In &lt;a href=&quot;../2024-11-19-turbo-drive-swiper-view-transitions/&quot;&gt;Challenge 35&lt;/a&gt; we built swiper-like page transitions with Turbo Drive, and in &lt;a href=&quot;../2025-06-10-turbo-streams-list-animation-view-transitions/&quot;&gt;Challenge 38&lt;/a&gt; we animated list appends with Turbo Streams. What we haven’t covered yet is the &lt;em&gt;shared element transition&lt;/em&gt;, the one you know from native apps where a thumbnail in a grid smoothly morphs into the hero image on the detail page.&lt;/p&gt;

&lt;p&gt;The View Transitions API makes this surprisingly straightforward. When two elements on different pages share the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;view-transition-name&lt;/code&gt;, the browser automatically interpolates between their position, size, and appearance during a navigation. You don’t need a JavaScript animation library or manual coordinate math - you assign a name, and the browser handles the rest.&lt;/p&gt;

&lt;p&gt;There’s one important constraint to keep in mind, though: that name must be unique per page. If two elements share the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;view-transition-name&lt;/code&gt; at the same time, the entire transition aborts silently. In a gallery grid with dozens of thumbnails, that’s something you’ll need to think about.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We have a simple photo gallery. The &lt;a href=&quot;https://stackblitz.com/edit/turbo-drive-shared-element-transitions?file=index.html&quot;&gt;index page&lt;/a&gt; shows a grid of cards, each with a thumbnail image and a title. Clicking a card navigates to a &lt;a href=&quot;https://stackblitz.com/edit/turbo-drive-shared-element-transitions?file=photo1.html&quot;&gt;detail page&lt;/a&gt; that shows the same image enlarged as a hero, plus a body of text.&lt;/p&gt;

&lt;p&gt;View Transitions are already enabled via the meta tag:&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://stackblitz.com/edit/turbo-drive-shared-element-transitions?file=index.html%3AL6&quot;&gt;https://stackblitz.com/edit/turbo-drive-shared-element-transitions?file=index.html%3AL6&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Right now, navigating between the index and a detail page triggers the default cross-fade, because no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;view-transition-name&lt;/code&gt; is set on any element yet. The &lt;a href=&quot;https://stackblitz.com/edit/turbo-drive-shared-element-transitions?file=view-transitions.css&quot;&gt;stylesheet&lt;/a&gt; already contains some basic transition configuration, but it’s waiting for the names to be wired up.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;Make the clicked card’s image smoothly morph into the hero image on the detail page, and back again when the user navigates with the browser back button.&lt;/p&gt;

&lt;p&gt;Two things need to happen:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Assign a unique &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;view-transition-name&lt;/code&gt; to each card image on the index page.&lt;/strong&gt; Each image needs its own name, because names must be unique per page. If two images share the same name, the transition breaks silently. Hint: inline styles are the cleanest way to bind a dynamic name to a DOM node.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Give the detail page’s hero image the same name as the card it came from.&lt;/strong&gt; When the browser sees the same name disappearing on the old page and appearing on the new one, it creates the morph animation automatically.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/turbo-drive-shared-element-transitions?file=index.html&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s what the result looks like - the thumbnail lifts out of the grid, scales up, and settles into the hero position on the detail page. Navigating back reverses the animation.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260512_Turbo_Drive_Shared_Element_View_Transitions.gif&quot; alt=&quot;Gallery grid where clicking a thumbnail morphs it into the hero image on the detail page, and browser back reverses the animation&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder:&lt;/strong&gt; the Node server simulates a Rails backend. If you make changes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.js&lt;/code&gt;, restart with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm start&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;

&lt;p&gt;What happens when the user navigates backward? The morph plays in reverse by default, which already looks good. But you can go further: Turbo sets a &lt;a href=&quot;https://turbo.hotwired.dev/handbook/drive#custom-rendering&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-turbo-visit-direction&lt;/code&gt;&lt;/a&gt; attribute on the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;html&amp;gt;&lt;/code&gt; element during rendering. Try using it to apply a different easing curve or duration for the reverse transition, so that returning to the grid feels distinct from opening a detail page.&lt;/p&gt;

&lt;h2 id=&quot;caveat&quot;&gt;Caveat&lt;/h2&gt;

&lt;p&gt;The uniqueness constraint on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;view-transition-name&lt;/code&gt; is strict. If the same name appears on more than one element at any point during the transition, the browser aborts the entire animation and falls back to a hard cut. This is easy to trip over in a gallery - if your index page renders 20 thumbnails and you give all of them &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;view-transition-name: hero&lt;/code&gt;, nothing will animate. Each card image needs its own scoped name.&lt;/p&gt;

&lt;p&gt;In a Rails context, this maps naturally to the model ID: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;style=&quot;view-transition-name: photo-&amp;lt;%= photo.id %&amp;gt;&quot;&lt;/code&gt;. On the detail page only one hero image exists, so there’s no collision risk - just make sure the name matches the one from the index.&lt;/p&gt;

&lt;p&gt;One more thing to watch for is Turbo’s page cache. If a cached version of the index page has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;view-transition-name&lt;/code&gt; set on elements that are also present in the incoming page, you might get duplicate names during the snapshot restoration. Test with caching enabled to make sure your transitions survive the round trip.&lt;/p&gt;
</description>
        <pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-05-12-turbo-drive-shared-element-view-transitions/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-05-12-turbo-drive-shared-element-view-transitions/</guid>
        
        <category>View Transitions</category>
        
        
        <category>Turbo Drive</category>
        
      </item>
    
      <item>
        <title>Turbo Frames - Error Boundaries</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;You’ve seen it: a Turbo Frame request hits a 500, and instead of anything useful, the user gets “Content Missing.” Or worse - a network blip leaves a lazy-loaded frame silently empty. No feedback, no way to recover.&lt;/p&gt;

&lt;p&gt;React solved this years ago with &lt;a href=&quot;https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary&quot;&gt;Error Boundaries&lt;/a&gt; - a wrapper component that catches failures in its children and renders fallback UI. We can do the same thing in Hotwire with a Stimulus controller that listens for &lt;a href=&quot;https://turbo.hotwired.dev/reference/events&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;&lt;/a&gt; and &lt;a href=&quot;https://turbo.hotwired.dev/reference/events&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;&lt;/a&gt;, shows a clean error state, and offers a retry button.&lt;/p&gt;

&lt;p&gt;Building on the frame lifecycle work from &lt;a href=&quot;../2026-01-20-turbo-frames-loading-spinner/&quot;&gt;Challenge 44&lt;/a&gt; (loading spinners) and &lt;a href=&quot;../2026-03-10-turbo-frames-form-loading-states/&quot;&gt;Challenge 46&lt;/a&gt; (form loading states) - this challenge handles the failure path.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We have a dashboard with three lazy-loaded Turbo Frames: &lt;strong&gt;Recent Activity&lt;/strong&gt;, &lt;strong&gt;Notifications&lt;/strong&gt;, and &lt;strong&gt;Billing&lt;/strong&gt;. Each frame has a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src&lt;/code&gt; attribute and loads independently on page load.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-error-boundaries?file=index.js&quot;&gt;server&lt;/a&gt; is a small Express app standing in for a Rails backend. It has three routes - but not all of them are healthy:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/frames/activity&lt;/code&gt; returns valid frame content.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/frames/notifications&lt;/code&gt; returns a &lt;strong&gt;500 error&lt;/strong&gt; (after a short delay) - a generic error page with no matching &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt;, which triggers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/frames/billing&lt;/code&gt; &lt;strong&gt;drops the connection&lt;/strong&gt;, simulating a network failure that triggers &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;. Note: this one takes a while to show — the browser needs to time out the request before the error fires.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Right now, the notifications frame shows “Content Missing” and the billing frame stays stuck on its loading state. The &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-error-boundaries?file=controllers%2Ferror_boundary_controller.js&quot;&gt;error_boundary_controller.js&lt;/a&gt; has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;connect()&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;showError()&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;retry()&lt;/code&gt; pre-filled. Your job is the two event handlers.&lt;/p&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;error-boundary&lt;/code&gt; controller lives on a wrapper &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;div&amp;gt;&lt;/code&gt; around each frame, with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;frame&lt;/code&gt; target and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; as an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;errorTemplate&lt;/code&gt; target. The action descriptors for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt; are wired on the frame element, bubbling up to the controller.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;One file needs work - &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-error-boundaries?file=controllers%2Ferror_boundary_controller.js&quot;&gt;error_boundary_controller.js&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;The controller skeleton already handles &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;connect()&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;showError(message)&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;retry()&lt;/code&gt; for you. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;showError&lt;/code&gt; method clones the error template, fills in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[data-role=&quot;message&quot;]&lt;/code&gt; element with the message you pass, and swaps it into the frame. Your job is the two event handlers — each one deals with a different type of failure:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Handle &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;.&lt;/strong&gt; In &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-error-boundaries?file=controllers%2Ferror_boundary_controller.js%3AL13&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;handleFrameMissing&lt;/code&gt;&lt;/a&gt;, call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.preventDefault()&lt;/code&gt; to suppress the “Content Missing” message. Then read &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.detail.response&lt;/code&gt; — it’s a standard &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Response&quot;&gt;Response&lt;/a&gt; object with the server’s error response. Extract the status code and pass a user-friendly message to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;showError()&lt;/code&gt;, e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;Something went wrong. Please try again later.&quot;&lt;/code&gt;&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Handle &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;.&lt;/strong&gt; In &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-error-boundaries?file=controllers%2Ferror_boundary_controller.js%3AL19&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;handleFetchError&lt;/code&gt;&lt;/a&gt;, call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.preventDefault()&lt;/code&gt; and pass a network-specific message to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;showError()&lt;/code&gt;. This time there’s no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.detail.response&lt;/code&gt; — the request never got a response. Instead, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.detail.error&lt;/code&gt; has the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error&quot;&gt;Error&lt;/a&gt; object describing the failure.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/turbo-frames-error-boundaries?file=controllers%2Ferror_boundary_controller.js&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s the end result - activity loads normally, notifications shows an error state instead of “Content Missing”, billing eventually shows a network error, and clicking Retry re-fetches:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260428_Turbo_Frames_Error_Boundaries.gif&quot; alt=&quot;Dashboard with three frames: activity loads successfully, notifications shows HTTP error state, billing shows network error state, clicking retry re-fetches&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder:&lt;/strong&gt; the Node server simulates a Rails backend. If you make changes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.js&lt;/code&gt;, restart with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm start&lt;/code&gt;.&lt;/p&gt;

&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;

&lt;p&gt;What if you added exponential backoff - automatically retrying 3 times with increasing delays before showing the error UI? And could you announce failures to screen readers using an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aria-live&lt;/code&gt; region on the error template?&lt;/p&gt;

&lt;h2 id=&quot;caveat&quot;&gt;Caveat&lt;/h2&gt;

&lt;p&gt;The two events cover different failure modes. &lt;a href=&quot;https://turbo.hotwired.dev/reference/events&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt;&lt;/a&gt; fires when the HTTP request succeeds (you get a response) but the HTML doesn’t contain a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; with a matching &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;id&lt;/code&gt; - the typical case for 4xx/5xx error pages. &lt;a href=&quot;https://turbo.hotwired.dev/reference/events&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:fetch-request-error&lt;/code&gt;&lt;/a&gt; fires when the request itself fails at the network level - no response at all. You need both for a robust error boundary.&lt;/p&gt;

&lt;p&gt;Also worth knowing: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;turbo:frame-missing&lt;/code&gt; provides &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.detail.visit&lt;/code&gt; - a function that replaces the entire page with the error response. That’s Turbo’s built-in escape hatch, useful for auth redirects (e.g., 401 → login page). The error boundary pattern is better for partial failures where the rest of the page should keep working.&lt;/p&gt;
</description>
        <pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-04-28-turbo-frames-error-boundaries/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-04-28-turbo-frames-error-boundaries/</guid>
        
        <category>Error Handling</category>
        
        <category>Error Boundaries</category>
        
        <category>Lazy Loading</category>
        
        
        <category>Turbo Frames</category>
        
        <category>Stimulus</category>
        
      </item>
    
      <item>
        <title>Turbo Streams - Custom Stream Actions - pushState</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;In &lt;a href=&quot;../2024-01-30-turbo-streams-custom-stream-actions-localstorage/&quot;&gt;Challenge 19&lt;/a&gt; — building on &lt;a href=&quot;../2023-08-01-turbo-streams-inline-stream-tags/&quot;&gt;Challenge 8&lt;/a&gt; (inline stream tags) and &lt;a href=&quot;../2023-08-15-turbo-streams-custom-stream-actions/&quot;&gt;Challenge 9&lt;/a&gt; (custom actions) — we stored ephemeral state in localStorage. That worked, but the browser’s Back and Forward buttons don’t know about it. Hit Back after switching versions three times? You leave the page entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/History/pushState&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;history.pushState()&lt;/code&gt;&lt;/a&gt; fixes this — but the part most people overlook is the first argument: the &lt;strong&gt;state object&lt;/strong&gt;. It stashes data directly in the history entry. When the user hits Back, &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;popstate&lt;/code&gt;&lt;/a&gt; hands that object back in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.state&lt;/code&gt;. No URL parsing, no guessing — your Stimulus controller reads the state and knows exactly what to restore. The &lt;strong&gt;URL&lt;/strong&gt; is for the user and for refresh; the &lt;strong&gt;state object&lt;/strong&gt; is for your JavaScript. One human-facing, the other machine-facing.&lt;/p&gt;

&lt;p&gt;Today we’re building a custom &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_state&lt;/code&gt; stream action (the server pushes URL &lt;em&gt;and&lt;/em&gt; state) and a Stimulus controller that reads it back on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;popstate&lt;/code&gt;. &lt;a href=&quot;https://github.com/marcoroth/turbo_power&quot;&gt;TurboPower&lt;/a&gt; ships this out of the box — here we’re building it from scratch.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We have a documentation page with a &lt;strong&gt;version selector&lt;/strong&gt; — a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt; dropdown that lets you switch between v1.0, v2.0, and v3.0 of an API. The selector lives inside a Stimulus controller (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version-selector&lt;/code&gt;), and the whole thing is wrapped in a form with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-turbo-stream&lt;/code&gt; set to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;true&lt;/code&gt; — which tells Turbo to send an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Accept: text/vnd.turbo-stream.html&lt;/code&gt; header, so the server knows to respond with Turbo Stream actions instead of a full HTML page.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=index.js&quot;&gt;server&lt;/a&gt; is a small Express app standing in for a Rails backend. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GET /versions/:version&lt;/code&gt; &lt;a href=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=index.js%3AL107&quot;&gt;route&lt;/a&gt; does what a Rails controller with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;respond_to { |f| f.turbo_stream }&lt;/code&gt; would do: it checks for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;switch&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restore&lt;/code&gt; params, sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Content-Type: text/vnd.turbo-stream.html&lt;/code&gt;, and returns a string of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; tags. If you make changes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.js&lt;/code&gt;, restart with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm start&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The server is ready to respond with Turbo Streams that replace the content area and the selector itself — but nothing works yet. That’s your job.&lt;/p&gt;

&lt;p&gt;The server already handles a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?version=&lt;/code&gt; query parameter on full page load — that part is done. What’s missing is the pushState/popstate wiring.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;Two files need work — &lt;a href=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=app.js&quot;&gt;app.js&lt;/a&gt; for the custom stream action, and the &lt;a href=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=controllers%2Fversion_selector_controller.js&quot;&gt;version_selector_controller.js&lt;/a&gt; for the Stimulus side:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Register a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_state&lt;/code&gt; custom stream action&lt;/strong&gt; in &lt;a href=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=app.js%3AL5&quot;&gt;app.js&lt;/a&gt;. The server already includes a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-stream action=&quot;push_state&quot;&amp;gt;&lt;/code&gt; tag in its response, with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;url&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt; attributes. Your action should read both and call &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/History/pushState&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;history.pushState(state, &quot;&quot;, url)&lt;/code&gt;&lt;/a&gt;. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state&lt;/code&gt; attribute is JSON — it carries the restore URL that the Stimulus controller will need later on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;popstate&lt;/code&gt;. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;url&lt;/code&gt; attribute is what the user sees in the address bar.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Implement &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;navigate&lt;/code&gt;&lt;/strong&gt; in the &lt;a href=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=controllers%2Fversion_selector_controller.js%3AL11&quot;&gt;Stimulus controller&lt;/a&gt;. When the user picks a version from the dropdown, update the form’s action to the selected value and submit it. The form already has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-turbo-stream&lt;/code&gt;, so this triggers a Turbo Stream response. Disable the select while the request is in flight to prevent double-clicks.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Implement &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restore&lt;/code&gt;&lt;/strong&gt; in the &lt;a href=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=controllers%2Fversion_selector_controller.js%3AL20&quot;&gt;Stimulus controller&lt;/a&gt;. This method is wired to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;popstate@window&lt;/code&gt; in the controller’s action descriptor. When the user hits Back or Forward, the browser hands you the state object you stashed earlier — it’s sitting right there in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event.state.restoreUrl&lt;/code&gt;. Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;get()&lt;/code&gt; from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@rails/request.js&lt;/code&gt; with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;responseKind: &quot;turbo-stream&quot;&lt;/code&gt; to fetch that URL. Notice: the restore URL already has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restore=1&lt;/code&gt; baked in, which tells the server to skip the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_state&lt;/code&gt; stream tag — otherwise you’d push a new history entry on every Back press, creating an infinite loop.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/turbo-streams-custom-actions-push-state?file=controllers%2Fversion_selector_controller.js&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s the end result — note the URL changing with each version switch, and Back/Forward restoring the correct version:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260414_Turbo_Streams_Custom_Stream_Actions_PushState.gif&quot; alt=&quot;Version selector switching content, URL updating, browser Back and Forward restoring previous versions, and refresh loading the correct version&quot; /&gt;&lt;/p&gt;
&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;

&lt;p&gt;Now that you’ve built &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;push_state&lt;/code&gt; from scratch, try dropping in &lt;a href=&quot;https://github.com/marcoroth/turbo_power&quot;&gt;TurboPower&lt;/a&gt; and replacing your hand-rolled action with theirs. What would it take to combine this pattern with Turbo’s morphing and page refreshes?&lt;/p&gt;

&lt;h2 id=&quot;caveat&quot;&gt;Caveat&lt;/h2&gt;

&lt;p&gt;A common stumbling point: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;popstate&lt;/code&gt;&lt;/a&gt; only fires on actual Back/Forward navigation, never when you call &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/History/pushState&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pushState()&lt;/code&gt;&lt;/a&gt;. The forward direction (user picks a version) is handled by the stream action; the backward direction (user hits Back) is handled by the Stimulus controller’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restore&lt;/code&gt; method. Two different mechanisms, one unified UX.&lt;/p&gt;
</description>
        <pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-04-14-turbo-streams-custom-stream-actions-push-state/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-04-14-turbo-streams-custom-stream-actions-push-state/</guid>
        
        <category>Custom Stream Actions</category>
        
        <category>History API</category>
        
        <category>pushState</category>
        
        
        <category>Turbo Streams</category>
        
        <category>Stimulus</category>
        
      </item>
    
      <item>
        <title>Turbo Frames - Chained Selects</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;Pick a country, then pick a city — you’ve seen this pattern a thousand times. The second dropdown depends on what you chose in the first. In jQuery-land this meant fetching JSON and manually rebuilding &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;option&amp;gt;&lt;/code&gt; elements. In React, you’d manage loading states, store the fetched options in component state, and re-render. With Turbo Frames, the server just re-renders the HTML you already have.&lt;/p&gt;

&lt;p&gt;The trick: wrap the dependent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;select&amp;gt;&lt;/code&gt; in a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt;, and when the first one changes, reload that frame with the selected value as a query parameter. The server filters the options and returns the updated frame. No JSON APIs, no manual DOM manipulation.&lt;/p&gt;

&lt;p&gt;The Stimulus controller that ties it together is surprisingly small.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We have a simple form with a &lt;strong&gt;Country&lt;/strong&gt; select. The city select doesn’t exist yet — it should only appear after a country has been chosen.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-chained-selects?file=index.js&quot;&gt;server&lt;/a&gt; has a small dataset of countries and their cities, and accepts a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;country_id&lt;/code&gt; query parameter to return the matching city list. But nothing in the frontend triggers that yet.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;There’s an empty &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-frame id=&quot;city_select&quot;&amp;gt;&lt;/code&gt; in the page. Your job is to wire up a &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-chained-selects?file=controllers%2Fchained_select_controller.js&quot;&gt;Stimulus controller&lt;/a&gt; that loads the city select into that frame when a country is chosen:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;When the country select changes, build a URL with the selected &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;country_id&lt;/code&gt; as a query parameter and set it as the city frame’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src&lt;/code&gt;. The server already knows how to return the right cities — it just needs the parameter.&lt;/li&gt;
  &lt;li&gt;When the country select is reset to the placeholder, the city frame should go back to being empty.&lt;/li&gt;
&lt;/ol&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/turbo-frames-chained-selects?file=controllers%2Fchained_select_controller.js%3AL16&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s a preview of the result:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260324_Turbo_Frames_Chained_Selects.gif&quot; alt=&quot;City select appearing after choosing a country, updating when the country changes&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;

&lt;p&gt;What if you had a third level — say, &lt;strong&gt;Country → City → Neighborhood&lt;/strong&gt;? Can you extend the pattern so picking a city loads a neighborhood select into yet another frame?&lt;/p&gt;
</description>
        <pubDate>Tue, 24 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-03-24-turbo-frames-chained-selects/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-03-24-turbo-frames-chained-selects/</guid>
        
        <category>select</category>
        
        <category>forms</category>
        
        <category>chained selects</category>
        
        <category>dependent dropdowns</category>
        
        
        <category>Turbo Frames</category>
        
        <category>Stimulus</category>
        
      </item>
    
      <item>
        <title>Turbo Frames - Form Submission Loading States</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;In the &lt;a href=&quot;/blog/2026-01-20-turbo-frames-loading-spinner/&quot;&gt;Loading Spinner&lt;/a&gt; challenge, we added busy indicators for Turbo Frame navigations — links that swap frame content asynchronously.&lt;/p&gt;

&lt;p&gt;But what about forms? When a user submits a form inside a Turbo Frame, Turbo handles the request and swaps the response, but gives &lt;strong&gt;zero visual feedback&lt;/strong&gt; while it’s in flight. The button stays clickable, nothing changes on screen, and the user is left wondering if their click registered — especially on slow connections.&lt;/p&gt;

&lt;p&gt;Back in the Rails-UJS days, we had &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-disable-with&lt;/code&gt; to handle this. Turbo 8 has its own answer, but it’s not automatic.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We have a simple guest book inside a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt;. The &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-form-loading-states?file=index.js&quot;&gt;server&lt;/a&gt; stores entries in memory and redirects back to the frame after a 1.5 second delay — simulating a slow save.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-form-loading-states?file=index.js%3AL26&quot;&gt;form&lt;/a&gt; lives inside the frame, so Turbo intercepts the submission and swaps the response automatically.&lt;/p&gt;

&lt;p&gt;Try it: submit an entry and notice the dead silence while the request is in flight. No spinner, no disabled button, no feedback at all.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;Make the form feel responsive during submission:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;The submit button should change its text and become disabled while the request is in flight. Look up Turbo 8’s successor to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data-disable-with&lt;/code&gt; in the &lt;a href=&quot;https://turbo.hotwired.dev/handbook/drive#form-submissions&quot;&gt;Turbo Handbook&lt;/a&gt;. You’ll need to edit the button markup in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;renderFrame()&lt;/code&gt; inside &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-form-loading-states?file=index.js&quot;&gt;index.js&lt;/a&gt; — remember to restart the server with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm start&lt;/code&gt; after making changes there.&lt;/li&gt;
  &lt;li&gt;The frame itself should show some visual indication that it’s busy. Remember: Turbo sets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[busy]&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[aria-busy]&lt;/code&gt; on frames during requests — how can you use that in CSS?&lt;/li&gt;
&lt;/ol&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/turbo-frames-form-loading-states?file=index.js,styles.css%3AL46&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s a preview of the end result:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260310_Turbo_Frames_Form_Loading_States.gif&quot; alt=&quot;Guest book form with loading feedback during submission&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;

&lt;p&gt;Can you combine this with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt;-based spinner approach from the &lt;a href=&quot;/blog/2026-01-20-turbo-frames-loading-spinner/&quot;&gt;Loading Spinner&lt;/a&gt; challenge?&lt;/p&gt;
</description>
        <pubDate>Tue, 10 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-03-10-turbo-frames-form-loading-states/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-03-10-turbo-frames-form-loading-states/</guid>
        
        <category>Form Submission</category>
        
        <category>forms</category>
        
        
        <category>Turbo Frames</category>
        
      </item>
    
      <item>
        <title>Turbo Frames - Using External Forms</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;Turbo Frames are a great way to compartmentalize your user interface, but sometimes they require workarounds when it comes to state management.&lt;/p&gt;

&lt;p&gt;One example of such a requirement is when you have to handle a form on your page that refers to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;input&amp;gt;&lt;/code&gt;’s in separate &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt;s.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We’re building upon the &lt;a href=&quot;/blog/2024-12-10-stimulus-turbo-frames-faceted-search/&quot;&gt;Faceted Search with Stimulus and Turbo Frames&lt;/a&gt; challenge here, but add a new requirement: Search results should also be sortable by publication year, author name (first name in this case) and book title.&lt;/p&gt;

&lt;p&gt;Remember that this uses a single &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-external-forms?file=controllers%2Ffaceted_search_controller.js&quot;&gt;Stimulus controller&lt;/a&gt; to wrap a form and swap a Turbo Frame’s contents to filter the results.&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;The goal is not to alter the Stimulus controller at all. We want to just append a new query parameter, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sort&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This query parameter could for example be set to&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name_asc&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name_desc&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;title_asc&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;title_desc&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now I’m going to require a bit fantasy from you, because we are going to look at the NodeJS part of our example here on Stackblitz. I’ve implemented the actual sorting in pure JavaScript here - you have to imagine that in fact we are looking at a Rails controller:&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-external-forms?file=index.js%3AL101&quot;&gt;https://stackblitz.com/edit/turbo-frames-external-forms?file=index.js%3AL101&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is sorted list is wrapped in a Turbo Frame inside the server response. What’s missing is the configuration of a select box that should&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;hold an option for every sort parameter and direction&lt;/li&gt;
  &lt;li&gt;somehow refer to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;form&amp;gt;&lt;/code&gt; that’s &lt;strong&gt;outside&lt;/strong&gt; the Turbo Frame, &lt;a href=&quot;https://stackblitz.com/edit/turbo-frames-external-forms?file=index.html%3AL46&quot;&gt;here&lt;/a&gt; (?! 🤯)&lt;/li&gt;
&lt;/ul&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/turbo-frames-external-forms?file=index.js%3AL51&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;To try this out you’ll have to kill the node server in Stackblitz and restart it with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm start&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here’s a preview of the result:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260203_Turbo_Frames_External_Forms.gif&quot; alt=&quot;User clicking through a faceted search form&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Granted, this is a bit of a contrived example (we could just hoist the sort dropdown to the form), but there will be other, more complex views where you’ll be glad you know about this approach. For example, you might want to reuse the turbo frame with sorting in other contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hint:&lt;/strong&gt; Look up a form input’s possible attributes on MDN!&lt;/p&gt;
</description>
        <pubDate>Tue, 03 Feb 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-02-03-turbo-frames-external-form/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-02-03-turbo-frames-external-form/</guid>
        
        <category>Form Submission</category>
        
        <category>forms</category>
        
        
        <category>Turbo Frames</category>
        
      </item>
    
      <item>
        <title>Turbo Frames - Loading Spinner</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;

&lt;p&gt;Asynchronously loading content via Turbo Frames sometimes comes with a user experience degradation because the frame doesn’t advertise its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;busy&lt;/code&gt; status, and/or becomes unresponsive.&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We start with a quite simplistic page that has a bullet point navigation and a Turbo Frame for the contents:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;ul&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;li&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;href=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/page1&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-turbo-frame=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;async-loader&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;Load Page 1&lt;span class=&quot;nt&quot;&gt;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;lt;li&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;href=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/page2&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-turbo-frame=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;async-loader&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;Load Page 2&lt;span class=&quot;nt&quot;&gt;&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;turbo-frame&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;async-loader&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;src=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/page1&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
  &amp;gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/turbo-frame&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Observe that the links in the navigation point to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async-loader&lt;/code&gt; Turbo Frame, i.e. its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src&lt;/code&gt; attribute is targeted.&lt;/p&gt;

&lt;p&gt;The NodeJS backend is set up so that it returns &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page1.html&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;page2.html&lt;/code&gt; respectively:&lt;/p&gt;

&lt;p&gt;https://stackblitz.com/edit/turbo-frames-loading-spinner?file=index.js%3AL12&lt;/p&gt;

&lt;p&gt;These two pages contain just the Turbo frame to exchange:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c&quot;&gt;&amp;lt;!-- page1.html --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;turbo-frame&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;async-loader&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;Page 1 took 1 sec to load&lt;span class=&quot;nt&quot;&gt;&amp;lt;span&amp;gt;&amp;lt;/turbo-frame&amp;gt;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;&amp;lt;!-- page2.html --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;turbo-frame&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;async-loader&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;Page 2 took 2 sec to load&lt;span class=&quot;nt&quot;&gt;&amp;lt;/turbo-frame&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;To implement loading/busy feedback, use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;lt;template&amp;gt;&lt;/code&gt; fragment supplied in the index page:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;  &lt;span class=&quot;nt&quot;&gt;&amp;lt;template&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-frame-spinner-target=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;placeholder&quot;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;↻ Spinner, &lt;span class=&quot;nt&quot;&gt;&amp;lt;em&amp;gt;&lt;/span&gt;can be any&lt;span class=&quot;nt&quot;&gt;&amp;lt;/em&amp;gt;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;&amp;lt;strong&amp;gt;&lt;/span&gt;HTML&lt;span class=&quot;nt&quot;&gt;&amp;lt;/strong&amp;gt;&amp;lt;/template&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;https://stackblitz.com/edit/turbo-frames-loading-spinner?file=index.html%3AL46&lt;/p&gt;

&lt;p&gt;The crucial point is to clone this fragment and insert it when the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;busy&lt;/code&gt; attribute appears, and remove it afterwards. For this we make use of a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MutationObserver&lt;/code&gt; in a Stimulus controller. Thankfully &lt;a href=&quot;https://stimulus-use.github.io/stimulus-use/#/use-mutation&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;useMutation&lt;/code&gt;&lt;/a&gt; from the Stimulus Use project makes this trivial. Insert the HTML swapping logic here:&lt;/p&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/turbo-frames-loading-spinner?file=controllers%2Fframe_spinner_controller.js%3AL20&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s a preview of the end result:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20260120_Turbo_Frames_Loading_Spinner.gif&quot; alt=&quot;Tabbed Turbo Frame navigation with loading display&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;teaser&quot;&gt;Teaser&lt;/h2&gt;
&lt;p&gt;There’s a way to make this work only with CSS if you don’t need any HTML in the “loading” display!&lt;/p&gt;
</description>
        <pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2026-01-20-turbo-frames-loading-spinner/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2026-01-20-turbo-frames-loading-spinner/</guid>
        
        <category>Lazy Loading</category>
        
        
        <category>Turbo Frames</category>
        
      </item>
    
      <item>
        <title>Stimulus - Web Share API</title>
        <description>&lt;h2 id=&quot;premise&quot;&gt;Premise&lt;/h2&gt;
&lt;p&gt;Among the more esoteric native browser APIs there’s one that is mostly overlooked (well, tbh most of them are) and thus lead to a whole host of third-party solutions when in fact the browser already includes this capability out of the box: The &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API&quot;&gt;Web Share API&lt;/a&gt;, which allows a site to share text, files, and URLs in a simple, standardized manner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Of course, there’s a caveat - Firefox does NOT support this functionality: &lt;a href=&quot;https://caniuse.com/web-share&quot;&gt;https://caniuse.com/web-share&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;starting-point&quot;&gt;Starting Point&lt;/h2&gt;

&lt;p&gt;We start with a grid of four images from &lt;a href=&quot;https://picsum.photos/&quot;&gt;https://picsum.photos/&lt;/a&gt;. In the caption of each image, next to the title there’s a link that shows a share button. This link already contains the binding to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;share&lt;/code&gt; Stimulus controller, along with several values and an action defined for each:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;an &lt;em&gt;action&lt;/em&gt; that points to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;share&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;title&lt;/code&gt; value that contains the image name (unoriginally just Picsum’s ID)&lt;/li&gt;
  &lt;li&gt;a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text&lt;/code&gt; value for sharing a description (in our case alt text) of the photo&lt;/li&gt;
  &lt;li&gt;a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;url&lt;/code&gt; value containing the image URL, and&lt;/li&gt;
  &lt;li&gt;a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;file&lt;/code&gt; value pointing to a downloadable resource to share (for demonstration purposes, again just the image URL).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href=&quot;https://stackblitz.com/edit/stimulus-web-share-api?file=index.html%3AL60&quot;&gt;https://stackblitz.com/edit/stimulus-web-share-api?file=index.html%3AL60&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;challenge&quot;&gt;Challenge&lt;/h2&gt;

&lt;p&gt;Your challenge is to use the Web Share API - most notably &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;navigator.share()&lt;/code&gt;&lt;/a&gt; and &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Navigator/canShare&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;navigator.canShare&lt;/code&gt;&lt;/a&gt; - to complete the sharing solution using the provided share data values:&lt;/p&gt;

&lt;iframe src=&quot;https://stackblitz.com/edit/stimulus-web-share-api?file=controllers%2Fshare_controller.js%3AL6&amp;amp;embed=1&quot; style=&quot;width: 100%; height: 480px&quot;&gt;&lt;/iframe&gt;

&lt;p&gt;Here’s a preview of the result on Mac OS:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/images/gen/blog/20251125_Stimulus_Web_Share_API.gif&quot; alt=&quot;Share dialogs being opened for four images&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;caveat&quot;&gt;Caveat&lt;/h2&gt;

&lt;p&gt;You will have to use feature detection using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;canShare&lt;/code&gt; for certain share data values!&lt;/p&gt;

</description>
        <pubDate>Tue, 25 Nov 2025 00:00:00 +0000</pubDate>
        <link>https://hotwire.club/blog/2025-11-25-stimulus-web-share-api/</link>
        <guid isPermaLink="true">https://hotwire.club/blog/2025-11-25-stimulus-web-share-api/</guid>
        
        <category>web apis</category>
        
        
        <category>Stimulus</category>
        
      </item>
    
  </channel>
</rss>