I have to relearn Angular’s Form API every time I use it
Angular’s ngModelController is suppose to make form handling easier, yet I find myself re-reading the API docs and googling for a tutorial every time I need to use it. The verbiage of the API docs never resonates with me in a way that sticks — and the step-by-step tutorials that I’ve encountered illustrate basic usage, but fall short in giving me a wholistic conceptual model and do not highlight gotchas. In this primer/refresher, I hope to address these pain points.
Pre-conditions: Angular 1.5.x + ngMessages
The Lifecycle within ngModelController
To me, the most elusive aspect of ngModelController is the execution order of its various pipelines. Before we dig into this lifecycle, let’s go over some terminology. Here, I will reference official angular docs and my interpretation of them in the form of KRLs — or Kim’s Reductionist Language — a mental translation that I prefer using because I find it easier to remember.
Model value: form control data
KRL: what I manipulate from the page controller
View value: template representation of the form control data
KRL: what the user sees
What the user enters into the form is not necessarily what you want to manipulate in the page controller. This is where parsers are handy.
$parsers: Array of functions to execute, as a pipeline, whenever the control reads value from the DOM.
KRL: a list of functions that turn user input into a controller-friendly value
Say you pre-populate a form and the data that is returned by the backend does not look like you want it to; you can use formatters to transform the model value into what is displayed to the user.
$formatters: Array of functions to execute, as a pipeline, whenever the model value changes
KRL: a list of functions that take in the controller-friendly value and turn it into a user-friendly value
Finally, you can use validators to block form submission and render error messages for invalid input.
$validators: A collection of validators that are applied whenever the model value changes.
KRL: A map of validator names to functions that take in the model value and view value as arguments. Validators return true if and only if the model value is valid. If a validator returns false, the error is stored in
ngModel.$error
andngForm.$error
;ngForm.$invalid
is set to true.
These definitions are straightforward enough, but it’s worthwhile to point out how they translate into practice. Take a basic directive that leverages the ngModelController API.
When the template renders…
Step 1 — $formatters run
When the template renders, $formatters
run first. I found this behavior curious because I did not explicitly change the model value after I initialized it and by the API definition, formatters only run when the model value has changed… so, what gives? As it turns out, Angular initializes $viewValue
and $modelValue
for ngModel instances to NaN
. Therefore, it registers a change in model value if the consumer initializes it to something else. If $scope.date
were initialized to NaN,
$formatters
would not run first.
Step 2 — $validators run
Similarly to formatters, validators run when the model value changes. Hence, it makes sense that given the model has changed from NaN
to our initialized value, validators run next. However, it’s worth noting that model watchers do a shallow compare. This means that if your model value was an object and a property value changed, formatters and validators would not re-run.
Assuming the user manually changes the input, then….
Step 3 — $parsers run
When the user manually changes the input value, parsers translate the new view value into a new model value.
Let me sidetrack for a moment to elaborate on a $parsers
gotcha that I encountered. From the docs:
Returning
undefined
from a parser means a parse error occurred. In that case, no$validators
will run and thengModel
will be set toundefined
unlessngModelOptions.allowInvalid
is set totrue
. The parse error is stored inngModel.$error.parse
.
The note is inconspicuous and frankly, my eyes glazed over it, but it merits explicit callout.
When I created my form, I gated form submission based on the form.$invalid
flag. I was puzzled when my form could not be submitted. From what I could tell, the validators that I registered via $validators
each had a useful error message. If my form was invalid, it follows that a message should appear explaining to the user why the form cannot be submitted. Right? Wrong.
My validators did not run because a parser returned undefined
. And because I did not realize that angular created $error.parse
on behalf of me, I did not think to create anng-message
for the parse error.
TL;DR: If you return undefined
from a parser, remember that it invalidates your form so make sure you render an appropriate message for $error.parse
.
Step 4. $validators run… most of the time
After the model value changes, validators run against the new model value. Makes sense — we have a new model value so we need to re-evaluate the validity of the form field. However, as mentioned in Step 3, validators do NOT run if there is a parse error. So, in reality, a more accurate title for this step is “Step 4: $validators run if there were no parse errors. Otherwise, skip to Step 5.” But that’s a lot of words for a title so I’m sticking with the one I got.
Since I’m talking about validators, I want to point out some feedback that I now keep in mind when creating them.
Here is a validator that I’ve written and am not proud of:
modelCtrl.$validators.required = (modelValue) => {
return modelValue &&
angular.isNumber(modelValue.min) &&
angular.isNumber(modelValue.max) &&
modelValue.min <= modelValue.max;
};
There are 2 things that I dislike about this validator.
First, I’ve overridden the default required
validator instead of leveraging $isEmpty
. The implication is that ngRequired
may no longer work as expected. By overriding required
directly, you can get into a state where $error.required
is true even if the ngRequired
condition dictates that the form field is not required. Here is the codepen to prove it.
To avoid this issue, override $isEmpty.
ngModelCtrl.$isEmpty = value => !(value.min || value.max);
Note how $isEmpty is used by ngRequired.
Second, I’ve conflated the meaning of required
. Not only does the required function check for the presence of min
and max
, it also checks that the min
value is less than max
and that both values are numbers. A better approach is to break the additional checks into their own validators. For example,
ngModelCtrl.$validators.inRange = (modelValue, viewValue) =>
{
const { min, max } = modelValue;
return ngModelCtrl.$isEmpty || (min >= 0 && min <= max);
};
Note how inRange
returns true when $isEmpty
is true. This pattern is similar to the implementation of ngMaxlength and was counterintuitive to me. It didn’t click until my colleague pointed out that it makes sense from a functional programming idiomatic perspective to let an error fall through if it is unrelated.
Like, if you go for [a functional programming paradigm], it feels nicer to just have one link in the pipeline that is adaptive and takes input->output as usual
In practice, it means to keep each validator focused and return false
only if the check is applicable to that part of the pipeline. Otherwise, return true
and let another validator catch the failure.
I’ve mentioned that $validators
run if the model value has changed. Now, suppose you have an attribute value that drives the outcome of a validator, like ngMaxlength. A change in the maxlength does not imply a change in the model value . This is a case where $validate
is useful — it lets you force re-validation so you can call it when the attribute value changes.
One final note on validation: if a validator fails, angular sets its model value to undefined
. To avoid this behavior, use ngModelOptions.allowInvalid=true
. Unless a parse error occurred, allowInvalid: true
persists the invalid model value instead of undefined
.
Step 5. $viewChangeListeners run … most of the time
Per docs,
$viewChangeListeners: Array of functions to execute whenever the view value has changed. It is called with no arguments, and its return value is ignored. This can be used in place of additional $watches against the model value.
I was surprised to find that in practice, $viewChangeListeners
do not run every time the DOM value has changed.
When there is a parse error, it is possible to have a new ngModel.$viewValue
and not run $viewChangeListeners
. I thought this was pretty weird, but it actually works as designed. In spite of its name, $viewChangeListeners
run when the model value has changed. If the model value remains undefined
multiple times in a row, then $viewChangeListeners
do not run. You can extrapolate this to mean that $viewChangeListeners
DO NOT run when:
- a parse error occurs consecutively
- the
$validators
pipeline returnsfalse
andallowInvalid
isfalse
$viewChangeListeners
highlights an important distinction — the view value is NOT necessarily the DOM value. The view value is actually the template representation of the current model value. As a matter of fact, $setViewValue
exists to persist the DOM value to ngModelController
’s internal view value because they can get out of sync.
$setViewValue should be called when a control wants to change the view value; typically, this is done from within a DOM event handler.
What is the internal view value used for? It gets passed through $parsers
and $validators
to create an internal model value. Yep. Similar to how there are two view values, there are two model values — an internal representation and scope expression (aka the model that the consumer manipulates from their page controller).
$setViewValue
is instrumental in ensuring the scope model value stays in sync with DOM changes. $setViewValue
stages the updated view value to run through $parsers
and $validators
and their output is stored as the internal model value, which eventually persists as the scope model value.
Remember how the docs say$formatters
run “whenever the model value changes”? Given this information, shouldn’t $formatters
run after $setViewValue
causes a model value change? Nope. More accurately, $formatters
run when the scope model value is different than the internal model value. When $setViewValue
causes a change in the internal model value, it makes matching changes to the scope model so it won’t trigger $formatters
. However, when the scope model is changed programmatically, the scope model becomes out of sync with the internal model and thus,$formatters
run, creating a new internal view value.
BUT how does this internal value turn into a DOM value? Cue $render.
After formatters output a new internal view value, $render
is called — and $render
uses the internal view value to update the DOM.
Rinse and Repeat
Each time the user manually changes the input, Steps 3–5 will run.
The Editable Canvas
The learnings on Angular’s Form API went into the editable canvas for Catalant’s enterprise platform.
If you’re curious, we were able to get our input mask to support many controls via transclusion — admittedly, this warrants its own article :), but I wanted to give you a flavor of what our templates look like.
<ep-inline-editable>
<input
class="form-control input-fixed-height"
ep-inline-editable-control
name="name"
ng-model="ctrl.name"
ng-model-options="{ updateOn: 'default blur' }"
type="text"
required> <ep-inline-editable-display>
{{ ctrl.name }}
</ep-inline-editable-display>
</ep-inline-editable>
Parting thoughts
To be totally honest, the series of surprising and unintuitive behaviors translated into non-trivial time for me… time spent on researching, understanding, debugging and experimenting. ngModelController API is more nuanced than it lets on and that’s why I felt the need to break it down step-by-step.
I hope this helps future consumers of ngModelController.
I’m always curious to hear other people’s thoughts and feedback — feel free to reach out at kim.win.dev@gmail.com