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

An "x-input" Property Binding Directive In Alpine.js

By
Published in Comments (4)

In my previous code kata on building a three-state toggle in Alpine.js, I used the x-effect directive to map outer scope properties onto the inner scope properties of my toggle component. Essentially, every time an outer scope property changed, the reactive aspects of the x-effect directive would turn around invoke my toggle component's reconcile() method, passing-in the updated property values. In other frameworks, this notion of input bindings and life-cycle hooks is more formally codified into the runtime. Taking inspiration from frameworks like Angular, I wanted to see if I could create an x-input directive in Alpine.js that would provide some of this behavior.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The primary goal of my x-input directive is to allow an outer scope property to be mapped onto an inner scope property such that an inner component wouldn't be so tightly coupled to the implementation details of the outer component. This decoupling is made possible because the mapping of values is done entirely within the HTML:

x-input:innerPropery="outerProperty"

Here, the outerProperty of the outer scope will be mapped onto the innerProperty of the inner scope. This allows the given component to operate solely on the this.innerProperty reference without having to know that outerProperty exists. An HTML element can have as many x-input attributes as it needs—one per bound property.

Aside: HTML attribute names aren't actually case sensitive. As such, innerProperty would need to be defined as :inner-property.camel; but, I left the line above in camel case to make it easier to read.

The secondary goal of my x-input directive is to allow a life-cycle method to be called whenever any of the x-input bindings is updated. In my implementation, I've called this method inputChanges(); and it passes-in a reference to the current collection of inputs as well as the previous collection of inputs. This is akin to how the $watch() magics work in Alpine.js (and as in many other frameworks).

To see this x-input in action, I created a very simple outer component that does nothing but maintain a counter:

/**
* I control the outer component.
*/
function OuterComponent() {

	return {
		counter: 10,

		/**
		* I increment the counter. The "x-input" directive(s) will then map the
		* outer value onto several inner properties of the inner component.
		*/
		increment() {

			this.counter++;

		}
	};

}

This counter value is then going to be passed into an inner component twice using two different x-input directives. Since directive attributes are all expressions in Alpine.js, I can perform maths on the input property as I'm wiring it into the inner component:

<div x-data="OuterComponent">
	<button @click="increment()">
		Increment: <span x-text="counter"></span>
	</button>

	<!-- Notice TWO x-input directives! -->
	<ul
		x-data="InnerComponent"
		x-input:inner-a.camel="( counter * 2 )"
		x-input:inner-b.camel="( counter * 3 )">
		<li>
			<strong>Inner A:</strong>
			<span x-text="innerA"></span>
		</li>
		<li>
			<strong>Inner B:</strong>
			<span x-text="innerB"></span>
		</li>
		<li>
			<strong>Total:</strong>
			<span x-text="total"></span>
		</li>
	</ul>
</div>

Notice that the expression (counter * 2) is being bound to the innerA property of the inner component. And, the expression (counter * 3) is being bound to the innerB property of the inner component. Internally, the inner component also has a total property which is the sum of innerA and innerB. This calculation is kept up-to-date within the inputChanges() life-cycle method discussed above.

Here's the implementation of the inner component:

/**
* I control the inner component.
*/
function InnerComponent() {

	// The "x-input" directives have ALREADY been bound on the "this" scope by the
	// time the "x-data" directive is executed. As such, we could make use of
	// "this.innerA" and "this.innerB" values within the component constructor if
	// we wanted to. But, I'm deferring to the init() life-cycle hook.
	return {
		total: 0, // ( this.innerA + this.innerB ) ... would have worked as well.

		/**
		* I get called once to initialize the component.
		*/
		init() {

			this.setTotal();

		},

		/**
		* I get called when any of the "x-input" bindings change.
		*/
		inputChanges( newInputs, oldInputs ) {

			this.setTotal();

			// Logging changes to see that this is actually working.
			console.group( "Inputs Changed" );
			console.log( `InnerA: ${ oldInputs.innerA } to ${ newInputs.innerA }.` )
			console.log( `InnerB: ${ oldInputs.innerB } to ${ newInputs.innerB }.` )
			console.groupEnd();

		},

		/**
		* I set the total based on the current "x-input" bindings.
		*/
		setTotal() {

			this.total = ( this.innerA + this.innerB );

		}
	};

}

Whenever the inputs are updated (by the outer component counter), the inputChanges() method is invoked, the changes are logged, and the total is recalculated. As such, when we run this demo, we get the following output:

The inputChanges() method is logging out the changes to the x-input directive bindings as the counter is incremented within the outer component.

As you can see, as the counter is incremented, both the bound properties and the total property are updated.

In the code above, notice that the .setTotal() method of the inner component is being called in two place: the inputChanges() life-cycle hook and the init() life-cycle hook. This "connascence of timing" may seem simple but it has architectural implications.

The init() life-cycle hook is invoked right after the x-data scope is added to the host element. Which means, if the x-input bindings are to be made available within the init() life-cycle hook, the x-input directive must have a higher priority than the x-data directive. And, in fact, when I'm defining this directive, I use the .before() modifier to make this happen:

document.addEventListener(
	"alpine:init",
	() => {

		// NOTE: We need the "x-input" directive to run BEFORE the "x-data"
		// directive so that the input bindings are available on the dataStack
		// at the time the "x-data" component has been evaluated (which is
		// inherently before the component's init() method executes).
		Alpine.directive( "input", InputDirective )
			.before( "data" )
		;

	}
);

Having the x-input directive execute before the x-data directive has implications for the scope chain. If we need to make the x-input bindings available within the init() life-cycle method, it means that we can't store the x-input references on the inner component scope—it simply doesn't exist yet. Furthermore, we can't store the x-input references on the outer component scope—we don't want to pollute the outer scope namespace.

What this means is that the x-input directives, bound to a given host element, have to create an intermediary scope in between the outer component and the inner component. This is akin to how the x-for directive works. The x-for directive creates an intermediary scope which contains the iteration and index values associated with the looping.

Aside: If you want to see another example of intermediary scope usage in Alpine.js, look at my x-template-outlet directive.

Alpine.js manages the scope chain by injecting an expando property into the DOM (Document Object Model). This property, _x_dataStack, is literally an array of all the scope proxies that the given element has access to. Each component then receives a merged version of all these scopes, which is essentially a "symmetrical access prototype chain" that Alpine.js implements via the Proxy object.

What this means is that if you look at the _x_dataStack DOM property of my inner component above, you'll see that it has three scopes in its proxy chain:

  • The InnerComponent x-data scope.
  • The x-input intermediary scope.
  • The OuterComponent x-data scope.

An HTML element can only have one x-data directive; and therefore, one x-data scope. But, it can have any number of x-input directives (our demo has two). I don't want each of these x-input directives to create a separate scope (one for each bound property). Instead, I want all of the x-input directives on a single element to act as a unified concept.

To do this, my x-input directive injects its own expando property, _x_inputScope, on the host element. This way, if I have multiple x-input instances on the same host element, the first instance will create the intermediary scope (and inject the expando property); and then, all subsequent instances will simply use the existing intermediary scope.

We can see this if we look at the DOM element properties in the Chrome dev tools:

The DOM properties panel in the Chrome dev tools showing that _x_inputScope points the same object that is in the _x_dataStack array.

Notice that the _x_inputScope proxy is also present in the _x_dataStack array.

Getting this all to work properly took me a few hours of trial and error and lots and lots of console-logging. But, I think I finally got something that works decently well. Here's my implementation of the x-input directive:

function InputDirective( element, metadata, framework ) {

	// Note: Even though the "x-input" directive is executing with a higher
	// priority than the "x-data" directive, the .closestRoot() method works by
	// walking up the DOM and looking for the "x-data" attribute. Therefore, even
	// though the "x-data" directive hasn't been initialized on this element yet,
	// as long as the "x-data" attribute is present, this closest-root check will
	// still be valid.
	if ( element !== Alpine.closestRoot( element ) ) {

		throw( new Error( "The [x-input] directive can only be used on an element that has an [x-data] directive." ) );

	}

	var inputScope = getInputScope();
	var inputName = metadata.value;
	var inputExpression = metadata.expression;
	var inputEvaluator = framework.evaluateLater( inputExpression );

	// Allow inputs like "x-input:my-name" to mapped locally as "myName".
	if ( metadata.modifiers.includes( "camel" ) ) {

		inputName = toCamelCase( inputName );

	}

	// Every time the outer value (expression) changes, we need to persist that
	// value to the input scope. This will always be executed BEFORE the
	// "inputChanges()" hook is called below.
	framework.effect(
		() => {

			inputEvaluator(
				( inputValue ) => {

					inputScope[ inputName ] = inputValue;

				}
			);

		}
	);

	/**
	* I get the input scope bound to the current element (or wire it up if it
	* hasn't yet been created).
	*/
	function getInputScope() {

		var domProperty = "_x_inputScope";
		var inputScope = element[ domProperty ];

		// Return the existing scope if possible.
		if ( inputScope ) {

			return inputScope;

		}

		// If no input scope exists, we need to add it to the dataStack for this
		// element. Since the "x-input" directive is being evaluated BEFORE the
		// "x-data" directive, it will live one level up from the "x-data" scope
		// (but within the same element).
		Alpine.addScopeToNode(
			element,
			inputScope = element[ domProperty ] = Alpine.reactive( Object.create( null ) )
		);

		var previousScope = Object.create( null );
		var fullScopeProxy = null;

		// We want to watch for all changes to the input scope so that we can
		// invoke the "changes" method as needed.
		framework.effect(
			() => {

				// This effect runs for the first time when the first "x-input"
				// directive is evaluated. However, at this time, the subsequent
				// "x-data" directive hasn't run yet (let alone any of the other
				// "x-input" directives that might be declared on this element).
				// As such, we have to wait for the length of the scope to change
				// before we initialize the full scope proxy. If we do this too
				// early, the "x-data" scope hasn't been "unshifted" onto the head
				// of data stack yet (and won't be included in the merged proxy).
				if ( ! Object.entries( inputScope ).length ) {

					return;

				}

				// If the length of the input scope has changed, it means that the
				// subsequent "x-input" directives have all been evaluated for the
				// first time; which also means that the "x-data" directive has
				// also been evaluated and added to the data stack. As such, we
				// can now define our full scope merge, which will included the
				// "x-data" entry.
				if ( ! fullScopeProxy ) {

					fullScopeProxy = Alpine.mergeProxies( Alpine.closestDataStack( element ) );
					Object.assign( previousScope, inputScope );
					// We're going to assume that any initial consumption of the
					// bound inputs will happen within the init() method of the
					// associated component. As such, we'll just short-circuit the
					// second run of this effect (which is the first run with all
					// of the initialized input values).
					return;

				}

				fullScopeProxy?.inputChanges( inputScope, previousScope );
				Object.assign( previousScope, inputScope );

			}
		);

		// Note: I'm not entirely sure how much stuff needs to be cleaned up in
		// this world of merged proxies and reactive scopes. As such, I may very
		// well be doing more here I have to.
		framework.cleanup(
			() => {

				// Remove DOM expando property.
				delete element[ domProperty ];
				// Free-up other memory references ???? Probably unnecessary.
				delete fullScopeProxy;
				delete inputScope;
				delete previousScope;

			}
		);

		return inputScope;

	}

	/**
	* I convert the given dashed value to a camel-case value.
	*/
	function toCamelCase( value ) {

		return value.toLowerCase().replace(
			/-(\w)/g,
			( $0, $1 ) => {

				return $1.toUpperCase();

			}
		);

	}

}

I think that a lot of what I try to do with Alpine.js actually cuts against the grain of what Alpine.js is good at. I think I try to shoe-horn too many Angular concepts into Alpine. But, if nothing else, these experiments get me to better understand the architecture of Alpine.js; and how all the data fits together.

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

Reader Comments

15,841 Comments

I'm realizing now that I am using Alpine.release() for the scope-level reactivity but not for the property-level reactivity. This stems from the fact that I'm not sure if I need to use it at all. I'm going to see if I can get some more information about this.

15,841 Comments

So, according to Eric Kwoka in my previously-linked Discussion, as long as you use the effect method that is passed into the directive definition, you don't have to release() it - it is doing some magic in the background to make this happen.

In my code, I'm actually using Alpine.effect(). I assumed that this was the same thing as the one passed into the directive. I didn't realize it was different. Later today, I'll try to go in an update the code to use the right one (and add a note as to why I'm using that one).

15,841 Comments

I've updated the code to use framework.effect(), not Alpine.effect(). This should now be automatically cleaning-up any reactive logic when then scope of the component is destroyed (or so I've been told).

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