Dynamically Updating Views With Turbo Streams Using Hotwire And Lucee CFML
As I demonstrated in my earlier post, Turbo Frames can be used to swap portions of a view using the response from a GET
page request. Hotwire takes that concept a step further with Turbo Streams. In response to a POST
form submission, a series of <turbo-stream>
elements can define multiple, independent mutations that Hotwire will perform on the currently rendered view. I wanted to explore the Turbo Streams mechanics in Lucee CFML.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
When Turbo Drive intercepts a native form submission, it prevents the default behavior and then makes the request using the fetch()
API. When it does this, it injects text/vnd.turbo-stream.html
into the Accept
HTTP request header. This header value signals to the ColdFusion server that the response can be composed of <turbo-stream>
elements instead of the traditional HTML (error response) or a Location
HTTP response header (success response).
A Turbo Stream response needs to be returned with the content type, text/vnd.turbo-stream.html
, and contain zero or more <turbo-stream>
elements. Each <turbo-stream>
element defines a single operation that Turbo Drive has to perform on the currently rendered view. The default Turbo Stream operations are:
append
prepend
replace
update
remove
before
after
That said, you can also define custom Turbo Stream actions for your application - but, I'm getting way ahead of myself. In this post, I just want to look at the very basics of the Turbo Stream workflow.
And, to do that, I'm going to create a simple ColdFusion application that renders a dynamic list of Counters. New counters can be added to the list. And, existing counters can be independently incremented or removed from the list. The state for the counters is persisted in a small ColdFusion component:
THREAD SAFETY: In the following ColdFusion code, you'll see that I am using the
++
operator on a cached value. The++
operator is not thread safe. In a production application, I would create thread safety by using anAtomicInteger
instead. However, for this simple demo, I'm not worrying about thread safety.
component
output = false
hint = "I provide a collection of incrementing counters. These are NOT intended to be thread-safe, and are just for a demo."
{
/**
* I initialize an empty collection of counters.
*/
public void function init() {
variables.counters = [:];
}
// ---
// PUBLIC METHODS.
// ---
/**
* I add a new counter. The new counter is returned.
*/
public struct function addCounter() {
var id = createUuid();
var counter = counters[ id ] = {
id: id,
value: 0
};
return( counter.copy() );
}
/**
* I return the collection of counters.
*/
public array function getAll() {
var asArray = counters.keyArray().map(
( id ) => {
return( counters[ id ].copy() );
}
);
return( asArray );
}
/**
* I increment the given counter. The existing counter is returned.
*/
public struct function incrementCounter( required string id ) {
var counter = counters[ id ];
counter.value++;
return( counter.copy() );
}
/**
* I remove the given counter. The removed counter is returned.
*/
public struct function removeCounter( required string id ) {
var counter = counters[ id ];
counters.delete( id );
return( counter );
}
}
As you can see, each counter has a UUID-based id
and a value
. The id
becomes important because <turbo-stream>
elements are (usually) applied to the existing DOM by way of an id
.
Modular And Reusable View Logic
Turbo Streams works by taking islands of server-side rendered HTML and swapping them into the client-side page. This means that any piece of dynamic content may have to be rendered by more than one route on your server. In order to prevent the duplication of logic, it appears that the cornerstone of a Turbo Streams applications is the use of small, modular, reusable Views.
To be clear, these modular views aren't dictated by the Hotwire framework; however, Hotwire works by way of "brute force". And, creating reusable views removes a lot of the work that goes into a brute force approach.
The core model for my ColdFusion demo is a "Counter". My default view contains a list of counters. And, my <turbo-stream>
responses will contain individual counters that need to be swapped into the live page. In order to remove duplication, I've created a ColdFusion Custom Tag that encapsulates the rendering of the counter widget:
<cfscript>
param name="attributes.counter" type="struct";
</cfscript>
<cfoutput>
<!---
Turbo Stream directives are driven by IDs. As such, we have to include an ID in
our counter view rendering.
--->
<div id="#encodeForHtmlAttribute( attributes.counter.id )#" class="m1-counter">
<div class="m1-counter__value">
#encodeForHtml( attributes.counter.value )#
</div>
<div class="m1-counter__body">
<!---
This form will either INCREMENT or REMOVE the current counter. In a
non-Turbo Drive world, this would direct the whole page to the given
action. However, if Turbo Drive intercepts the action, it will be
performed via fetch() and we have a chance to respond with a Turbo Stream
that will update the rendered DOM (Document Object Model).
--->
<form method="post" class="m1-counter__form">
<input type="hidden" name="id" value="#encodeForHtmlAttribute( attributes.counter.id )#" />
<button type="submit" formAction="increment.htm">
Increment
</button>
<button type="submit" formAction="remove.htm">
Remove
</button>
</form>
<span class="m1-counter__timestamp">
Counter rendered at #timeFormat( now(), "HH:mm:ss.l" )#
</span>
</div>
</div>
</cfoutput>
<!--- This tag does not expect any body content. --->
<cfexit method="exitTag" />
As you can see, this counter.cfm
custom tag renders the given counter's value and provides form actions for increment.htm
and remove.htm
. Each of these form actions will result in a response that contains Turbo Stream directives that mutate the current page.
Also notice that each counter includes the current timestamp. This is important because it will help us see when each counter instance is being rendered (and re-rendered).
Bring it all Together With Turbo Drive
Now that we have an encapsulated rendering of the Counter, we can easily render it in multiple places in our ColdFusion application. The first place being our index page. This page just renders the list of counters with the ability to append new counters to the list:
<cfscript>
counters = application.counters.getAll();
</cfscript>
<cfmodule template="./tags/page.cfm">
<cfoutput>
<h1>
ColdFusion + Hotwire Turbo Stream Demo
</h1>
<div id="counters">
<cfloop item="counter" array="#counters#">
<!---
The key to using Turbo Streams (from what I am understanding) is that
you have to create modular view components such that the rendering
logic for a given piece of UI (user interface) is centralized. This
way, it's easy to render a given view from a variety of places without
duplicating the view logic. Here, we're rendering our Counter using a
ColdFusion custom tag.
--->
<cfmodule
template="counter.cfm"
counter="#counter#">
</cfmodule>
</cfloop>
</div>
<form method="post" action="add.htm">
<button type="submit">
Add New Counter
</button>
</form>
</cfoutput>
</cfmodule>
As you can see, each counter is being rendered by an instance of our ColdFusion custom tag.
And, at the bottom of our page, we have a form that posts over to the add.htm
route. The default behavior of this form POST
is to navigate the user to the add.htm
route, perform the action, and then redirect the user back to the index page. However, once Turbo Drive kicks in, the form submission will be intercepted and subsequently executed via fetch()
.
What this means is that the add.htm
route needs to have two behaviors:
If the HTTP request was initiated by the browser, respond with a
Location
header.If the HTTP request was initiated by Hotwire, respond with a
<turbo-stream>
element.
We can create control flow around these behaviors by inspecting the Accept
HTTP header (as mentioned above). Here's my add.htm
page:
<cfscript>
counter = application.counters.addCounter();
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Turbo Drive adds a special "Accept" header value for FORM actions.
isTurboStreamAllowed = getHttpRequestData( false )
.headers
.accept
.findNoCase( "text/vnd.turbo-stream.html" )
;
// We're going to treat the Turbo Drive interactions as a PROGRESSIVE ENHANCEMENT;
// which means that, without Turbo Drive, this page request will be made as a normal,
// top-level request. In that case, we just want to redirect the user back to the home
// page, where the entire list of counters will be re-rendered.
if ( ! isTurboStreamAllowed ) {
location( url = "index.htm", addToken = false );
}
</cfscript>
<!---
If we made it this far, we know that the request is being executed by the Turbo Drive
API client, which means we can render a list of Turbo Stream elements. In this case,
now that we've added a new counter, we need to APPEND the rendering of the new counter
to the counters container.
--->
<cfcontent type="text/vnd.turbo-stream.html; charset=utf-8" />
<cfoutput>
<turbo-stream action="append" target="counters">
<template>
<cfmodule
template="counter.cfm"
counter="#counter#">
</cfmodule>
</template>
</turbo-stream>
</cfoutput>
As you can see, for non-Turbo Drive requests, I perform the .addCounter()
call and then immediately redirect the user back to the index page where the full list of counters (including the newly created one) will be re-rendered. However, for a Turbo Drive request, I render a Turbo Stream response. This response includes a single <turbo-stream>
directive which includes a rendering of a newly created counter. And, as you can see, I'm using the ColdFusion custom tag defined above in order to remove View duplication.
Note that the <turbo-stream>
directives contains a target
attribute. This attribute contains an ID which much correspond to an ID in the already rendered page - this is how Turbo Stream maps operations to the live DOM.
With this add.htm
route and logic in place, we can see that adding a new counter to the list works via fetch()
without refreshing the page:
As you can see, Hotwire Turbo Drive intercepted the form POST
, executed it via fetch()
, and then our ColdFusion route returned a <turbo-stream>
response to append
the new counter rendering to the page. Notice that the timestamps for the two counters are different; this is because we did not re-render the entire page - we only added new content to the existing page.
Each counter has an Increment and Remove form POST
. These ColdFusion routes are essentially copies of the Add route, but with a different counter action. Here's the increment.cfm
:
<cfscript>
param name="form.id" type="string";
counter = application.counters.incrementCounter( form.id );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Turbo Drive adds a special "Accept" header value for FORM actions.
isTurboStreamAllowed = getHttpRequestData( false )
.headers
.accept
.findNoCase( "text/vnd.turbo-stream.html" )
;
// We're going to treat the Turbo Drive interactions as a PROGRESSIVE ENHANCEMENT;
// which means that, without Turbo Drive, this page request will be made as a normal,
// top-level request. In that case, we just want to redirect the user back to the home
// page, where the entire list of counters will be re-rendered.
if ( ! isTurboStreamAllowed ) {
location( url = "index.htm", addToken = false );
}
</cfscript>
<!---
If we made it this far, we know that the request is being executed by the Turbo Drive
API client, which means we can render a list of Turbo Stream elements. In this case,
now that we've incremented the counter, we need to REPLACE the old DOM rendering with
a new DOM rendering (of the counter).
--->
<cfcontent type="text/vnd.turbo-stream.html; charset=utf-8" />
<cfoutput>
<turbo-stream action="replace" target="#encodeForHtmlAttribute( counter.id )#">
<template>
<cfmodule
template="counter.cfm"
counter="#counter#">
</cfmodule>
</template>
</turbo-stream>
</cfoutput>
As you can see, increment.cfm
is almost word-for-word the same as add.cfm
. The relevant difference being that our Turbo Stream response contains a replace
operation, not an append
operation.
The remove.cfm
ColdFusion page is exactly the same, only it returns a remove
Turbo Stream action instead of replace
:
<cfscript>
param name="form.id" type="string";
counter = application.counters.removeCounter( form.id );
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
// Turbo Drive adds a special "Accept" header value for FORM actions.
isTurboStreamAllowed = getHttpRequestData( false )
.headers
.accept
.findNoCase( "text/vnd.turbo-stream.html" )
;
// We're going to treat the Turbo Drive interactions as a PROGRESSIVE ENHANCEMENT;
// which means that, without Turbo Drive, this page request will be made as a normal,
// top-level request. In that case, we just want to redirect the user back to the home
// page, where the entire list of counters will be re-rendered.
if ( ! isTurboStreamAllowed ) {
location( url = "index.htm", addToken = false );
}
</cfscript>
<!---
If we made it this far, we know that the request is being executed by the Turbo Drive
API client, which means we can render a list of Turbo Stream elements. In this case,
now that we've removed the counter, we need to REMOVE the rendering of the counter
from the counters container.
--->
<cfcontent type="text/vnd.turbo-stream.html; charset=utf-8" />
<cfoutput>
<turbo-stream action="remove" target="#encodeForHtmlAttribute( counter.id )#">
<!--- No content is expected. --->
</turbo-stream>
</cfoutput>
As you can see, the <turbo-stream>
directive is of action remove
; and, Turbo Drive knows which element to remove on the current page based on the target
attribute, which must contain a unique ID.
With these two ColdFuison routes in place, we can now update and remove counters in our application:
As you can see, each of my ColdFusion endpoints is being invoked by Turbo Drive using the fetch()
API. And each of those ColdFusion endpoints responds with a Turbo Stream directive which tells Hotwire how to update the currently rendered view.
Struggling to Wrap My Head Around a New Kind of API
The mechanics of taking a ColdFusion request and then responding with a Turbo Stream response are not that difficult. But, I must admit that I am struggling to wrap my head around how to actually architect a ColdFusion application that does this gracefully.
I'm used to creating an API that responds with JSON (JavaScript Object Notation) that can be generically consumed by any number of views within the client-side application. Turbo Stream responses, however, are not generic at all - they are highly coupled to a specific View rendering.
So, how do I code for that on the server-side? Do I create view-specific API end-points? Or, do I stop thinking about a traditional "API", per say, and start thinking about additional "Controller Methods" for the current view? Much of what I see in the Ruby on Rails seems to take the latter approach.
Want to use code from this post? Check out the license.
Reader Comments
I just published a related post in which I demonstrate that Turbo Stream elements can be returned in any response - it doesn't necessarily have to be a dedicated
text/vnd.turbo-stream.html
response:www.bennadel.com/blog/4414-including-inline-turbo-stream-actions-in-hotwire-and-lucee-cfml.htm
I can see this being quite useful for allowing transient parts of pages (such as flash messages) to be updated in normal redirects.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →