Skip to main content
Ben Nadel at the New York ColdFusion User Group (Jul. 2009) with: Sean Schroeder and Matt Levine
Ben Nadel at the New York ColdFusion User Group (Jul. 2009) with: Sean Schroeder Matt Levine

Creating A Template-Outlet Directive In Alpine.js 3.13.5

By
Published in Comments (5)

In my previous post, I looked at cloning templates in Alpine.js. More specifically, I was looking at the mechanics of supplying intermediary data to the cloned element that exists in between the clone's local x-data binding and the ancestral scope chain. In that post, I was explicitly cloning a template in my controller logic. In this post, I want to explore a more declarative cloning technique using a template outlet in Alpine.js.

Aside: I'm borrowing the idea of "template outlets" from Angular.

Consider an Alpine.js application that has the given template:

<template x-ref="source">
	<p x-data="{ thing: cloneType }">
		I am a <span x-text="thing"></span>.
	</p>
</template>

What I want to do is "stamp out" copies of that template elsewhere in the HTML markup. And, in order to do that, I need to create some sort of "hook"—something that tells Alpine.js, "create a copy of that template here!".

This "hook" is my "template outlet" directive. It serves two purposes:

  1. Define the insertion point for the cloned template.

  2. Map external data onto internal data.

Given the previous template, let's say that I want to create a copy of it and I want to define cloneType as "widget". To do that, I would use my x-template-outlet directive and give it an x-data binding that defines cloneType:

<template
	x-template-outlet="$refs.source"
	x-data="{ cloneType: 'widget' }">
</template>

Now, when the x-template-outlet directives clones the template reference, it puts {cloneType:"widget"} in the scope chain, which allows it to be referenced within the x-data binding of the clone.

To explore this, I'm going to create a template ($refs.source) that defines a "Friend" component. Notice that the friend() controller accepts two constructor arguments: friendID and friendName.

<template x-ref="source">
	<p x-data="friend( friendID, friendName )">
		<strong x-text="id"></strong>:
		<span x-text="name"></span>
	</p>
</template>

We're going to use our x-template-outlet directive to then create copies of this Friend component. And, as we do, each outlet's x-data scope binding will provide the constructor arguments for friendID and friendName:

<template
	x-template-outlet="$refs.source"
	x-data="{
		friendID: ++id,
		friendName: 'Bobbi'
	}">
</template>
<template
	x-template-outlet="$refs.source"
	x-data="{
		friendID: ++id,
		friendName: 'Kimmi'
	}">
</template>
<template
	x-template-outlet="$refs.source"
	x-data="{
		friendID: ++id,
		friendName: 'Sandi'
	}">
</template>

If we then run this Alpine.js code and look at the DOM bindings, we can see that all three friends were created and each node's expando property shows us the correct scope chain:

DOM showing the scope chain for the clone Friend component.

As you can see, all three Friend clones were rendered to the DOM. And, when we look at one of the clones, we can see that it has three scopes in its scope chain:

  1. It's own, local x-data scope.

  2. The intermediary scope provided by our x-template-outlet directive that setup the mappings for friendID and friendName to be consumed in the constructor injection of the friend() component.

  3. The application's x-data scope.

It's not so easy to see in this screenshot; but, you might notice that there is no <template> in the DOM where the outlet is. Instead, I've swapped out the x-template-outlet element with an HTML Comment that acts as the insertion hook.

Here's the code that powers this exploration:

<!doctype html>
<html lang="en">
<body>

	<h1>
		Creating A Template-Outlet Directive In Alpine.js 3.13.5
	</h1>

	<div x-data="app">

		<!--
			This template contains an inert component ("friend") that we're going to
			render several times using the "x-template-outlet" directive. The constructor
			arguments in this component (friendID, friendName) will be supplied via the
			"x-data" scope on the "x-template-outlet".
		-->
		<template x-ref="source">
			<p x-data="friend( friendID, friendName )">
				<strong x-text="id"></strong>:
				<span x-text="name"></span>
			</p>
		</template>

		<!--
			We're using the template-outlet to render the "source" template reference
			several times. For each rendering, we'll use the "x-data" binding to supply
			constructor arguments for the "friend" controller.
		-->
		<template
			x-template-outlet="$refs.source"
			x-data="{
				friendID: ++id,
				friendName: 'Bobbi'
			}">
		</template>
		<template
			x-template-outlet="$refs.source"
			x-data="{
				friendID: ++id,
				friendName: 'Kimmi'
			}">
		</template>
		<template
			x-template-outlet="$refs.source"
			x-data="{
				friendID: ++id,
				friendName: 'Sandi'
			}">
		</template>

	</div>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
	<script type="text/javascript">

		document.addEventListener(
			"alpine:init",
			function setupAlpineBindings() {

				Alpine.data( "app", AppController );
				Alpine.data( "friend", FriendController );
				Alpine.directive( "template-outlet", TemplateOutletDirective );

			}
		);

		/**
		* I control the app component.
		*/
		function AppController() {

			return {
				thing: "App",
				id: 0
			}

		}

		/**
		* I control the friend component.
		*/
		function FriendController( friendID, friendName ) {

			// NOTE: If we didn't want to use constructor arguments, we could have also
			// used "this.$data.friendID" and "this.$data.friendName" magic references
			// within our constructor logic.
			return {
				thing: "Friend",
				id: friendID,
				name: friendName
			};

		}

		/**
		* I clone and render the given source template.
		*/
		function TemplateOutletDirective( element, metadata, framework ) {

			// Get the template reference that we want to clone and render.
			var templateRef = framework.evaluate( metadata.expression );

			// Clone the template and get the root node - this is the node that we will
			// inject into the DOM.
			var clone = templateRef.content
				.cloneNode( true )
				.firstElementChild
			;

			// CAUTION: The following logic ASSUMES that the template-outlet directive has
			// an "x-data" scope binding on it. If it didn't we would have to change the
			// logic. But, I don't think Alpine.js has mechanics to solve this use-case
			// quite yet.
			Alpine.addScopeToNode(
				clone,
				// Use the "x-data" scope from the template-outlet element as a means to
				// supply initializing data to the clone (for constructor injection).
				Alpine.closestDataStack( element )[ 0 ],
				// use the template-outlet element's parent to define the rest of the
				// scope chain.
				element.parentElement
			);

			// Instead of leaving the template in the DOM, we're going to swap the
			// template with a comment hook. This isn't necessary; but, I think it leaves
			// the DOM more pleasant looking.
			var domHook = document.createComment( ` Template outlet hook (${ metadata.expression }) with bindings (${ element.getAttribute( "x-data" ) }). ` );
			domHook._template_outlet_ref = templateRef;
			domHook._template_outlet_clone = clone;

			// Swap the template-outlet element with the hook and clone.
			// --
			// NOTE: Doing this inside the mutateDom() method will pause Alpine's internal
			// MutationObserver, which allows us to perform DOM manipulation without
			// triggering actions in the framework. Then, we can call initTree() and
			// destroyTree() to have explicitly setup and teardowm DOM node bindings.
			Alpine.mutateDom(
				function pauseMutationObserver() {

					element.after( domHook );
					domHook.after( clone );
					Alpine.initTree( clone );

					element.remove();
					Alpine.destroyTree( element );

				}
			);

		}

	</script>

</body>
</html>

Note to Self: I'm attaching my own expando properties to the hook comment node (_template_outlet_ref and _template_outlet_clone). I think I might need to remove those in a cleanup() call if the comment is ever removed from the DOM. Leaving those in might create some sort of memory leak?? Not sure.

In my cloning algorithm, my Alpine.addScopeToNode() call assumes that the x-template-outlet element has its own x-data binding. But, this isn't strictly a requirement. If I wanted to make this more robust, I would have to test the element to see if it contained an "x-data" attribute; and, if not, I would have to use a different call to set the scope bindings.

With this post, I'm getting closer to being able to implement some sort of recursive component rendering!

UPDATE: 2024-03-01

I've updated the DOM manipulation portion of the directive and wrapped it in an Alpine.mutateDom() call. It seems that all of the structural directives in Alpine.js follow this pattern.

// Swap the template-outlet element with the hook and clone.
// --
// NOTE: Doing this inside the mutateDom() method will pause Alpine's internal
// MutationObserver, which allows us to perform DOM manipulation without
// triggering actions in the framework. Then, we can call initTree() and
// destroyTree() to have explicitly setup and teardowm DOM node bindings.
Alpine.mutateDom(
	function pauseMutationObserver() {

		element.after( domHook );
		domHook.after( clone );
		Alpine.initTree( clone );

		element.remove();
		Alpine.destroyTree( element );

	}
);

Once I insert the clone, I ask Alpine to initialize it. And, once I remove the element (the template hook), I ask Alpine to tear it down. I'm hoping that this will prevent any memory leaks. I can also confirm that this does call the cleanup() callback (though I don't have one defined in this demo).

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

Reader Comments

15,883 Comments

So, it seems that when the template is cloned and appended to the current parent which is being evaluated, this works fine. However, I'm finding that if I use this in other parts of the DOM tree (that has already been initialized), the changes don't always take effect. The behind-the-scenes data is there, but the DOM doesn't "react".

I'm finding that if I try to do what x-if and x-for are doing, and wrap it in a mutateDom() call and then explicitly call initTree() then it works more consistently. So, instead of just calling this:

element.after( domHook );
domHook.after( clone );
element.remove();

I am calling this:

Alpine.mutateDom(
	() => {
		element.after( domHook );
		domHook.after( clone );
		element.remove();

		Alpine.initTree( clone );
	}
);

But, I'm not sure if this is "proper". Among the possible issues, I am not sure that Alpine.js will know to "clean up" the element after I call element.remove()?

I'm trying to dig in and understand the life-cycle stuff a bit better.

15,883 Comments

In the code in the post, I am using Alpine.addScopeToNode() to copy the data-stack from the <template> element over to the clone. And, that code assumes that the template has an x-data attribute. To simplify, I think I can just copy the internal expando property directly:

clone._x_dataStack = Alpine.closestDataStack( element );

This somewhat abuses the private variables. But, it seems like that actually happens quite a bit in the directives that I've seen.

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