Skip to main content
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Yogesh Mathur
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Yogesh Mathur

Dynamically Updating Views With Turbo Streams Using Hotwire And Lucee CFML

By
Published in , Comments (1)

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 an AtomicInteger 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:

A new counter is added to the current view. And, clicking on the network activity shows a call to add.htm that results in a Turbo Stream response.

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:

Multiple counters being maniupated without refreshing the page by using Turbo Drive.

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

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel