Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Ben Michel
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Ben Michel

Selecting Portions Of A Turbo Stream Template With Custom Actions

By
Published in , Comments (1)

In the Hotwire JavaScript framework, Turbo Streams give us the ability to manipulate the DOM (Document Object Model) in response to POST requests (and some GET requests). These DOM manipulations are performed through a set of defined "actions". Turbo provides some default actions; but, we can create our own custom Turbo Stream actions in order to add even more functionality. One thing that I would love to have is a [selector] attribute on the <turbo-stream> element that would limit the operation to a sub-tree of the <template> content. I believe that this would give me more flexibility when it comes to reusing my ColdFusion templates.

View this code in my ColdFusion + Hotwire Demos project on GitHub.

Out of the box, Turbo Stream provides the following actions:

  • replace
  • update
  • after
  • append
  • before
  • prepend

With each of these actions, the entirety of the Turbo Stream <template> is used to alter the target element. Sometimes, however, I don't want to use the whole template. For example, if I'm re-rendering a form with an error message, I might only want to use the error message portion of the ColdFusion template (so that the user focus doesn't get removed from the current active form field). In that case, I would want to limit the Turbo Action operation to just the error message DOM element.

To achieve this, I've taken the native actions from above and created new ones all suffixed with, With. replace becomes replaceWith, append becomes appendWith, and so on. The actions work exactly the same way as the native actions (I've copied the source code right out of GitHub); but, instead of using the raw template content, they generate a new, intermediary DocumentFragment using .querySelectorAll().

Here's the Function I have for extracting the DOM sub-tree using the selector attribute:

/**
* I get the DocumentFragment to use as the Turbo Stream payload for the given Turbo Stream
* element. If no selector is provided, the original template is returned. If a selector is
* provided, a new fragment will be generated using the selector and returned.
*/
function getFragmentUsingSelector( turboStreamElement ) {

	var originalFragment = turboStreamElement.templateContent;
	var selector = turboStreamElement.getAttribute( "selector" );

	// If no selector is provided, use the entire template - this is the same behavior as
	// the relevant native action.
	if ( ! selector ) {

		return( originalFragment );

	}

	// Locate the desired sub-nodes within the template. In the vast majority of cases,
	// this will likely be a SINGLE root node. But, I'm using querySelectorAll() in order
	// to make the Stream action a bit more flexible.
	var nodes = originalFragment.querySelectorAll( selector );
	// Construct a new document fragment using the selected sub-nodes.
	var fragment = document.createDocumentFragment();
	fragment.append( ...nodes );

	return( fragment );

}

As you can see, this takes the content of the <template> (which Turbo Stream is cloning internally), locates the DOM nodes using .querySelectorAll(), and then appends those DOM nodes into a new fragment. This is the fragment that is ultimately applied in the Turbo Stream custom actions.

To create the custom actions, I went into the GitHub repo for Turbo, copied the native actions, and then replaced all of the .templateContent references with calls to the above above function. Here's me full demo JavaScript file:

// Import core modules.
import * as Turbo from "@hotwired/turbo";
import { StreamActions } from "@hotwired/turbo";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

/**
* I get the DocumentFragment to use as the Turbo Stream payload for the given Turbo Stream
* element. If no selector is provided, the original template is returned. If a selector is
* provided, a new fragment will be generated using the selector and returned.
*/
function getFragmentUsingSelector( turboStreamElement ) {

	var originalFragment = turboStreamElement.templateContent;
	var selector = turboStreamElement.getAttribute( "selector" );

	// If no selector is provided, use the entire template - this is the same behavior as
	// the relevant native action.
	if ( ! selector ) {

		return( originalFragment );

	}

	// Locate the desired sub-nodes within the template. In the vast majority of cases,
	// this will likely be a SINGLE root node. But, I'm using querySelectorAll() in order
	// to make the Stream action a bit more flexible.
	var nodes = originalFragment.querySelectorAll( selector );
	// Construct a new document fragment using the selected sub-nodes.
	var fragment = document.createDocumentFragment();
	fragment.append( ...nodes );

	return( fragment );

}

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// In the following custom Turbo Stream actions, I basically went into the Turbo source
// code for StreamActions:
// --
// https://github.com/hotwired/turbo/blob/main/src/core/streams/stream_actions.ts
// --
// ... copied the logic, and replaced the raw .templateContent references with a call to
// extract the sub-tree of the template using the SELECTOR (and the Function above).

StreamActions.replaceWith = function() {

	this.targetElements.forEach(
		( targetElement ) => {

			targetElement.replaceWith( getFragmentUsingSelector( this ) );

		}
	);

}

StreamActions.updateWith = function() {

	this.targetElements.forEach(
		( targetElement ) => {

			targetElement.innerHTML = "";
			targetElement.append( getFragmentUsingSelector( this ) );

		}
	);

}

StreamActions.afterWith = function() {

	this.targetElements.forEach(
		( targetElement ) => {

			targetElement.parentElement?.insertBefore(
				getFragmentUsingSelector( this ),
				targetElement.nextSibling
			);

		}
	);

}

StreamActions.appendWith = function() {

	this.removeDuplicateTargetChildren();
	this.targetElements.forEach(
		( targetElement ) => {

			targetElement.append( getFragmentUsingSelector( this ) );

		}
	);

}

StreamActions.beforeWith = function() {

	this.targetElements.forEach(
		( targetElement ) => {

			targetElement.parentElement?.insertBefore(
				getFragmentUsingSelector( this ),
				targetElement
			);

		}
	);

}

StreamActions.prependWith = function() {

	this.removeDuplicateTargetChildren();
	this.targetElements.forEach(
		( targetElement ) => {

			targetElement.prepend( getFragmentUsingSelector( this ) );

		}
	);

}

To test this, I've created a small ColdFusion demo in which I have a series of <form>s that all POST to the same Turbo Stream end-point. This end-point always returns the same <turbo-stream> payload; but, it varies in the way in which the [action] and [selector] attributes are populated (via the URL query-string). Notice that the <template> below contains a number of paragraphs, each with a class that corresponds to one of the above custom actions:

<cfscript>

	param name="request.context.action" type="string";
	param name="request.context.selector" type="string";

</cfscript>
<cfcontent type="text/vnd.turbo-stream.html; charset=utf-8" />
<cfoutput>

	<!---
		Note that this CUSTOM Turbo-Stream action is accepting a **SELECTOR** attribute.
		The SELECTOR attribute allows us to target portions of the TEMPLATE element such
		that we can more flexibly reuse existing interfaces (by including them, but
		selecting only a portion of the interface as the Turbo-Stream payload).
	--->
	<turbo-stream
		action="#request.context.action#"
		selector="#request.context.selector#"
		target="demo-container">
		<template>
			<p class="new replace-with">
				Selected via 'replaceWith'
			</p>
			<p class="new update-with">
				Selected via 'updateWith'
			</p>
			<p class="new prepend-with">
				Selected via 'prependWith'
			</p>
			<p class="new append-with">
				Selected via 'appendWith'
			</p>
			<p class="new before-with">
				Selected via 'beforeWith'
			</p>
			<p class="new after-with">
				Selected via 'afterWith'
			</p>
		</template>
	</turbo-stream>

</cfoutput>

All of those paragraphs will be returned to the front-end every time the stream end-point is requested. However, with our custom actions, only a portion of the template will actually be used to manipulate the client-side DOM.

Here's my ColdFusion page testing the custom actions. Each <form> represents a different custom action:

<cfmodule template="./tags/page.cfm">
	<cfoutput>

		<div id="demo-container">
			<p class="original">
				To be changed via Turbo Streams.
			</p>
		</div>

		<!---
			Each of the following forms is going to POST to an end-point that returns a
			Turbo Stream element with a CUSTOM ACTION. These custom actions are named for
			the native Turbo Stream actions along with the suffix "With" (for example,
			"replace" becomes "repalceWith"). The custom actions allow for an additional
			attribute, "selector", which determines which elements within the TEMPLATE
			will be used in the DOM manipulation. For the sake of the demo, I'm providing
			these attributes via hidden inputs:
		--->
		<form method="post" action="stream.htm">
			<input type="hidden" name="action" value="prependWith" />
			<input type="hidden" name="selector" value=".prepend-with" />
			<button type="submit">
				Prepend With
			</button>
		</form>
		<form method="post" action="stream.htm">
			<input type="hidden" name="action" value="appendWith" />
			<input type="hidden" name="selector" value=".append-with" />
			<button type="submit">
				Append With
			</button>
		</form>
		<form method="post" action="stream.htm">
			<input type="hidden" name="action" value="updateWith" />
			<input type="hidden" name="selector" value=".update-with" />
			<button type="submit">
				Update With
			</button>
		</form>
		<form method="post" action="stream.htm">
			<input type="hidden" name="action" value="beforeWith" />
			<input type="hidden" name="selector" value=".before-with" />
			<button type="submit">
				Before With
			</button>
		</form>
		<form method="post" action="stream.htm">
			<input type="hidden" name="action" value="afterWith" />
			<input type="hidden" name="selector" value=".after-with" />
			<button type="submit">
				After With
			</button>
		</form>
		<form method="post" action="stream.htm">
			<input type="hidden" name="action" value="replaceWith" />
			<input type="hidden" name="selector" value=".replace-with" />
			<button type="submit">
				Replace With
			</button>
		</form>

	</cfoutput>
</cfmodule>

Each of these <form> is going to manipulate the DIV at the top with id="demo-container". And, if we run this ColdFusion demo and submit each form in series, we get the following output:

A series of individual paragraphs is applied to the live page in response to Turbo Stream operations, one paragraph per operation.

Now remember, in each of the form posts, all of the paragraphs are being returned in the Turbo Stream response. However, as you can see in the GIF, only a single paragraph is applied to the DOM for each Turbo Stream operation. This is because the selector attribute limits the scope of the content that is extracted from each <template>.

Right now, when I need to update the DOM (inside a <turbo-frame>) in response to a form POST, I need to break my view rendering up into smaller, reusable pieces so that I re-render parts of it inside a <turbo-stream> element. By creating custom actions that can pin-point elements within a view, I no longer have to break-up my views. Now, I should be able to just re-render the root view but only consume part of that view in the Turbo Stream operation.

I'll try to make this more tangible in a follow-up post.

Want to use code from this post? Check out the license.

Reader Comments

15,848 Comments

I've been thinking more about this, and doing some experimentation, and I'm thinking that maybe using querySelectorAll() is a problem. I'm thinking that maybe it should only select a single node, ala querySelector(). And, the reason for this is that having only a single selected node allows other operations to being defined on the context extraction.

For example, if I have selector=".myDiv", I might then also have an attribute like contents="true", that might indicate that whatever operation I'm performing, I want to do it with the contents of the .myDiv element, not then element itself. So, basically, am I using the innerHTML or the outerHTML.

If I have multiple elements selected with my [selector], this gets a bit more dicey.

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