An "x-input" Property Binding Directive In Alpine.js
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.](https://bennadel-cdn.com/resources/uploads/2024/x-input-directive-for-property-binding-alpine@2x.gif)
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.](https://bennadel-cdn.com/resources/uploads/2024/x-input-scope-expando-property@2x.png)
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
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.I opened a Help question in the Alpine Github discussions forum: Clarity on when / if I need to call
.release()
on an effect reference?I'll report back when I have more clarity.
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 torelease()
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).I've updated the code to use
framework.effect()
, notAlpine.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 →