Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: David Boon
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: David Boon

Cloning Templates In Alpine.js 3.13.5

By
Published in Comments (1)

Over my last few blog posts, I've developed a better understanding of how scopes are applied in an Alpine.js application. Each x-data node receives an "expando" property (_x_dataStack) which contains an array of reactives scopes that represent the "scope chain" for the given component. This understanding filled-in the missing pieces that I needed in order to understand <template> cloning. Specifically, I now understand how and why an intermediary scope can be applied during the cloning process.

Consider a simple Alpine.js application that has an x-for range iteration:

<div x-data="app">
	<template x-for="i in 10">
		<span x-data="item" x-text="i"> ... </span>
	</template>
</div>

In this Alpine.js app, the <template> element is cloned 10 times; and, for each clone - each x-for iteration - the i variable is set. But, where does i live? It's clearly not in the app controller, since it has to be unique for each cloned template. And, it can't be in the item controller, since the item controller defines its own scope internally.

If you inspect the generated DOM elements, and you look at the expando property, what you'll see is that the i variable is defined in a hidden, intermediary scope that lives in between the item and the app controller scopes:

[ itemScope, { i: 1 }, appScope ]

Since Alpine.js will traverse the scope chain looking for values, any reference to i from within item will end up finding and consuming this intermediary scope value.

Using this information, we can now create our own <template> cloning algorithms that apply an intermediary scope in order to supply clone-specific data. In the following Alpine.js demo, we have a button and a template. Every time we click the button, the template will be cloned; and, a unique, monotonic ID will be supplied in this hidden, intermediary scope. This hidden scope is applied via the Alpine.addScopeToNode() method.

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

	<h1>
		Cloning Templates In Alpine.js 3.13.5
	</h1>

	<div x-data="app">
		<button @click="doCloning()">
			Make Clone
		</button>
		<!--
			This Template node will be cloned and appended to the app. In doing so, the
			app will provide it with its own unique "cloneID" scope variable.
		-->
		<template x-ref="source">
			<p x-data="clone">
				<span x-text="thing"></span> -
				<span x-text="cloneID"></span> -
				<button @click="incrementID()">
					Increment ID
				</button>
			</p>
		</template>
	</div>

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

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

				Alpine.data( "app", AppController );
				Alpine.data( "clone", CloneController );

			}
		);

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

			var host = this.$el;
			var cloneID = 100;

			return {
				thing: "App",
				doCloning: doCloning
			};

			/**
			* I clone the <template> and append it to the end of the host container.
			*/
			function doCloning() {

				var clone = this.$refs.source.content
					.cloneNode( true )
					.firstElementChild
				;

				// Before we append the clone to the DOM, which will inherently apply a
				// new scope due to the use of "x-data", we want to explicitly apply an
				// intermediary scope that represents this cloning operation. This scope
				// will sit in between the host scope and the clone scope. Think of this
				// as being akin to the "x-for" iteration scope.
				var hiddenScope = Alpine.reactive({
					thing: "metaClone",
					cloneID: ++cloneID
				});
				Alpine.addScopeToNode(
					clone,       // The node receiving the intermediary scope.
					hiddenScope, // The reactive scope being applied.
					host         // The node representing the "parent scope" provider.
				);

				// Now that the intermediary scope has been applied, we can append the
				// node to the DOM. At this point, the MutationObserver will take over and
				// Alpine.js will work its normal magic.
				host.append( clone );

			}

		}

		/**
		* I control the clone component.
		*/
		function CloneController() {

			return {
				thing: "Clone",
				incrementID: incrementID
			};

			/**
			* I increment the cloneID, which is defined and mutated within the
			* intermediary scope that we applied via the "Alpine.addScopeToNode()" during
			* the cloning process.
			*/
			function incrementID() {

				this.cloneID++ ;

			}

		}

	</script>

</body>
</html>

As you can see, when the button is clicked and the doCloning() method is called, we perform a 3-part process:

  1. Clone the template.
  2. Apply an intermediary scope (with cloneID).
  3. Append the clone to the active DOM.

When step 3 is performed, and the clone is appended to the DOM, Alpine.js' MutationObserver will kick in, it'll see the new node and it'll apply the x-data attribute mechanics. The x-data directive will then create its own reactive scope and "unshift" it onto the head of the scope chain.

If we run this Alpine.js demo and look at the one of the clone elements, we can see that its expando property contains three scopes: the clone scope, the intermediary scope (with the unique cloneID), and the app scope:

Template clones being applied to the DOM. Inspecting one of the clone reveals 3 scopes: the clone scope, the app scope, and the intermediary scope with the cloneID property.

As you can see in the expando property, each clone has three scopes; the middle of which is the hidden, intermediary scope that we applied using Alpine.addScopeToNode().

Now that I have a better understanding of how scopes fit together and how they are applied to the DOM, it opens up some interesting opportunities in Alpine.js. For example, this now makes recursive components possible (I think).

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