AngularJS component binding types — my experiences.
Component bindings from Angular 1.5 had few difficulties. Although my problems may seem simple for experienced developers, I hope this post will help to protect somebody from unnecessary waste of time.
One-way (“<”) data bindings
Problem: passing value vs passing reference
One-way data bindings are quite well-known and really helpful with clear data flow (new data always come from parent), but it’s easy to mess up — when you pass object/array to binding you can unwillingly manipulate it’s values inside a component. This is JS behavior, not Angular fault — but it’s easy to think that Angular will take care about it, while it won’t.
What is real example of this situation? Imagine you have a select
component which encapsulates common behavior like option translation, proper styling etc, and it takes array with options as binding:
<select-component
options="$ctrl.options"
></select-component>
Solution: always copy and assign to new fields non-primitive inputs.
Just use angular.copy(). But it has to be done wisely.
Code like this won’t work:
function ComponentController ($window) {
var $ctrl = this;
$ctrl.internalOptions = $window.angular.copy($ctrl.options);
}
Because bindings are not assigned before $onInit() hook beginning from Angular 1.6.
So, the second attempt may include $onInit:
function ComponentController ($window) {
var $ctrl = this;
$ctrl.$onInit = function () {
$ctrl.internalOptions = $window.angular.copy($ctrl.options);
}
}
While $onInit has its use cases, it can’t deal with changes which come later. So what now? $onChanges
hook should be used:
- It deals great with later changes, which occur after component is initialized, when input value changes.
- What is even more important,
$onChanges
is great solution when value can be loaded asynchronously, and bindings are empty even in$onInit
.
So, final code:
function ComponentController ($window) {
var $ctrl = this;
$ctrl.$onChanges = function (changes) {
if (changes.options) {
$ctrl.internalOptions = $window.angular.copy($ctrl.options);
}
}
}
(You can easily simulate asynchronous loading behavior with $q.when())changes.options.currentValue
can be used as source to copy as well, but inside $onChanges
hook changes are also available in options
input itself.
So, my way of avoiding problems with references passed to inputs is to copy them inside $onChanges
hook.
Expressions (“&”) bindings
First problem: how the heck is it working at all?
For a long period of time, I have problem with understanding how expressions bindings works, with components and also, with directives — so you have an idea how long took me to understand it. So I used to pass it as simple, two-way binding:
var selectComponent = {
controller: ComponentController,
templateUrl: 'select-component.html',
bindings: {
changeHook: '=',
},
};angular.module("app").directive("selectComponent", selectComponent);function ComponentController () {
var $ctrl = this;
$ctrl.$onChange = function () {
$ctrl.changeHook($ctrl.name, $ctrl.value);
}
}// And component usage
<select-component
change-hook="$ctrl.updateModel"
></select-component>
But I knew that this was ugly by-pass for my lack of knowledge, experience and lack of desire to read more about it.
Solution of first problem: really quick explanation
I read great article by Aviv Ben-Yosef: Understanding Angular’s & Binding and I saw a hope. Despite the fact that I won’t use expression binding in other way that as function binding (but maybe I will see things like on-change="$ctrl.value=value"
in future, so thank you Aviv for explanation!), it’s as easy as that:
- You provide a callback like
change-hook="$ctrl.updateModel(name, value)"
- If you want to call this callback inside component with internal component values, you provide an object with names of parameters as keys:
$ctrl.changeHook({name: $ctrl.name, value: $ctrl.value})
. Key thing is that key names should be exactly the same as in HTML where you provide callback to component — otherwise, for example when you call it like this:$ctrl.changeHook({name1: $ctrl.name, value1: $ctrl.value})
callback will be called withname
asundefined
andvalue
asundefined
.name1
andvalue1
values will be lost, cause they do not match parameter names. That’s how I think about it — ifname
is not specified in object, Angular looks forname
variable on$scope
(which still exists even in component world), but it is not defined so name isundefined
.
I used to use the same names in HTML, where callback is provided: change-hook="$ctrl.updateModel(name, value)"
and in component’s controller: $ctrl.updateModel = function (name, value){}
. But, in retrospect, I don’t think this is a good idea-I’ll show you why in next paragraph.
Other ways to inject value to expression binding and how conflicts are resolved
We can just hard-code a value, which is useful when function takes more than one argument, and component does not inject it:
//HTML
<some-component
change-hook="$ctrl.lowerCaseAndUpdateCode('languageCode', value)"
>
</some-component>//CALLBACK'S CALL INSIDE COMPONENT
$ctrl.changeHook({value: $ctrl.value});//PARENT CODE
$ctrl.lowerCaseAndUpdateCode= function (name, value) {
$ctrl.codes[name] = _toLowerCase(value);
}
But, if we have this situation:
//ALMOST THE SAME CODE, BUT SEE BOLD CODE//HTML
<some-component
change-hook="$ctrl.lowerCaseAndUpdateCode('languageCode', value)"
>
</some-component>//CALLBACK'S CALL INSIDE COMPONENT
$ctrl.changeHook({name: $ctrl.name, value: $ctrl.value});//PARENT CODE
$ctrl.lowerCaseAndUpdateCode = function (name, value) {
$ctrl.codes[name] = _toLowerCase(value);
}
For me, it could create some confusion — component injects name
, but there is no name
in expression provided in HTML. On the other hand, first parameter of callback declaration has name
as its first argument.
What will happen?
If you do not forget that object should be provided to callback’s call inside component, and object keys should be the same as parameter names in HTML where callback is provided, you now what will happen — name
from $ctrl.changeHook({name: $ctrl.name, value: $ctrl.value});
is ignored and languageCode
is provided as value for first argument of lowerCaseAndUpdateCode
function. That’s why, from a perspective of time, different names should be provided for HTML expression binding and for callback declaration in parent code:
//HTML
<some-component
change-hook="$ctrl.lowerCaseAndUpdateCode('languageCode', value)"
>
</some-component>//CALLBACK'S CALL INSIDE COMPONENT
$ctrl.changeHook({name: $ctrl.name, value: $ctrl.value});//PARENT CODE
$ctrl.lowerCaseAndUpdateCode = function (parameterName, parameterValue) {
$ctrl.codes[parameterName] = _toLowerCase(parameterValue);
}
It’s more readable, at least for me.
Evaluated DOM attribute (“@”) bindings
First problem: rely on attribute binding for for boolean-like values
How it works? It takes what is written written and makes a string:
var someComponent = {
controller: ComponentController,
templateUrl: 'some-component.html',
bindings: {
required: '@',
},
};angular.module("app").directive("someComponent", someComponent);<some-component
required="true"
></some component
So, when you use it like this:
<some-component
required="false"
></some component
And inside component’s template (some-component.html) you have:
<span
ng-if=”$ctrl.required”
>
<span aria-hidden="true"></span>
<span class="sr-only">Field is required</span>
</span>
You will be really surprised, and not in positive way.
What the heck happened?
As I wrote, it took attribute value:
// false is attribute value - "false" as string, not boolean!
<some-component
required="false"
></some component
And make a string, so required
value is string, not boolean. So in code ng-if="$ctrl.required”
it casts to boolean true
. You can check it now, without any effort — just open Developer Console in your browser, type !!(“false”)
and you will get true as value of this expression. Nothing wrong in this behavior, any JS string with length greater than zero casts to true — I’m pretty sure that it happens for every language with dynamic types. But personally I like the semantic of required="true"
, its readability and I don’t want to lose it.
Solution: create boolean value based on attribute binding value
The way to solve this problem is to transform value from string to boolean and assign result to new value:
function ComponentController ($window) {
var $ctrl = this;
$ctrl.$onInit = function () {
$ctrl.isRequired = $ctrl.required === 'true';
}
}
And in view:
<span
ng-if=”$ctrl.isRequired”
>
<span aria-hidden="true"></span>
<span class="sr-only">Field is required</span>
</span>
Now, until true
is provided, $ctrl.isRequired
is always set to false
. Is $onInit
good for this transformation? Yes, but as I mentioned earlier it can’t deal with changes after component is initialized or with situation when binding is loaded asynchronously, so it’s better to use $onChanges
:
$ctrl.$onChanges = function (changes) {
if (changes.required) {
$ctrl.isRequired = $ctrl.required === true;
}
}
Now, component is ready for parent’s data reload or for future value changes. But how to provide value when it’s changed?
Second problem: dynamic values for evaluate DOM attribute binding
First attempt may look like this:
<some-component
required="$ctrl.isRequired"
></some component
At first glance, it should work: every change should be grabbed in $onChanges
hook. But it won’t — because attribute binding will take attribute as it is, and real value of required
binding is… $ctrl.isRequired
. What we can do about it? We can evaluate it:
<some-component
required="{{$ctrl.isRequired}}"
></some component
And it may evaluated as boolean true
or false
, null
or undefined
—we are ready for this situation, cause internal component’s variable is set to true
only when true
literal is value of attribute.
Important warning about attribute binding
Cause it’s take attribute as it is, string 'required'
will evaluate to "'required'"
, and ="required" !== "'required'"
. So you have to be really careful to not provide attributes with quotes!
Conclusion
Intentionally I omitted two-way binding (=
), as it should be avoided if possible. I hope this post will help somebody to avoid wasting time, especially at the beginning of work with Angular JS component input bindings.