Watching A Collection Of Expressions Using Scope.$watchGroup() In AngularJS
Since the early days, AngularJS has provided us with the Scope.$watch() method as a means to observe changes in the View-Model. Then, they added the Scope.$watchCollection() to perform a shallow-watch or arrays. Now, in AngularJS 1.3, they've added Scope.$watchGroup(). This new method watches a collection of expressions and then invokes the callback if any one of the expression results changes.
Internally, AngularJS only uses the Scope.$watchGroup() method in one place - inside the $interpolate() service. I don't have any immediate thoughts on where else it might be useful; but, I figured I should take a look at how it works and then let it marinate in the back of my mind.
Under the hood, AngularJS takes the collection of expressions and binds each individual expression to a Scope.$watch() callback. Then, when one of the $watch() callbacks is invoke, AngularJS turns around and invokes our $watchGroup() callback. This means that Scope.$watchGroup() can accept anything that Scope.$watch() can accept.
In order to not explode the number callback invokations, changes are chunked and scheduled asynchronously. This way, if multiple expressions change in a single digest lifecycle, our $watchGroup() callback is only invoked once, at the end of the digest (using the Scope.$evalAsync() method).
To see this in action, I've created a demo that watches a variety of expressions and then logs the resultant values. You'll see that my expressions are being provided in a variety of ways:
As you can see, I'm using a variety of expressions, generated in a variety of different ways. These all work because they all boil down to functions that accept the Scope / Context as their first argument and then return a value.
When we run the above code, we get the following console output:
The group has changed in some way:
>> Heather is awesome!
>> isBFF: true
>> Secret handshake: true
The group has changed in some way:
>> Georgia is awesome!
>> isBFF: false
>> Secret handshake: true
As you can see, the callback was invoked twice - once for the watch initialization and then once when the view-model changed in the $timeout() service. Of particular interest, notice that the one-time binding expression didn't change, even when the associated view-model did. Very interesting stuff.
Want to use code from this post? Check out the license.
This post looks a little lonely... :-)
Just thought I'd give you an example of where I've had to use $watchGroup. I've recently implemented some form validations where I wanted to display multiple error messages per input (e.g. required, minlength, maxlength, etc...). I also wanted to use the Bootstrap styling for form errors, including the glyphicon-ok and glyphicon-remove stuff, and keep it all in sync with $touched, $dirty, and $invalid. Plus, I'm lazy, so I wanted my error messages to be based on a per-error template. That way, I wouldn't have to write the same error strings over and over again. Now, I could have implemented a ton of divs or spans with ng-hide, but I like to keep the HTML side of things as simple as possible. Besides, this kind of stuff is what custom directives are for, right?
Initially, inside my link function, I created two $watch expressions: one to handle invalid states -- setting the has-error class on the form-group element, adding the error icon, showing the help-block, etc...; and one to handle valid states -- setting has-success, toggling the success icon, and hiding the help-block. My two $watch expressions were simply functions returning "$touched && $dirty && $valid" and "$touched && $dirty && $invalid". This worked great, until I started adding multiple validation rules to the inputs.
With multiple rules, I noticed some weird edge cases in the transitions from one failed validation to another, like minlength to required. Let's say my input has "ng-minlength=3" and "required" (actually, minlength could be any value...). After triggering $touched and $dirty, I was getting the correct minlength error message with less than 3 characters in the input. However, when I deleted those characters, the error message didn't change to the corresponding required message. After puzzling a bit, I realized that "$touched && $dirty && $invalid" wasn't a fine enough granularity for my $watch expression -- it wasn't changing as I moved directly from one error state to another.
This is getting pretty long, so I'm going to cut to the chase. I created a second $watch expression function to return "Object.keys($error).join()", to handle the changing error states, and combined the two expressions in a $watchGroup. I tried using just the $error object in a $watch, and monitoring for $touched and $dirty inside the listener, but that produced different edge cases. I finally realized that the only way to catch everything correctly was to have two independent watches calling a single listener. Now, it works great! All the transitions are caught correctly and all the correct error messages are displayed. (If you're wondering about the valid side, "$touched && $dirty && $valid" works perfectly, since there is only one valid state to monitor...) So, I wound up with a $watch for the the valid states and a $watchGroup for the invalid states.
Just an example of $watchGroup in the wild. Sorry for the length...
Very interesting stuff. I'm not as familiar with all the form-based validation (been a long-standing blind-spot in my Angular learning, both NG1 and NG2). So, I basically understand what you're saying, but I don't have an instinct about it. What you're doing sounds cool, though. Thanks for the letting me know!
Yeah, I've been doing mostly backend and database work for 20+ years, but every once in a while I have to validate a form or create some kind of admin system. I refactored the bootstrap validation styling down to one $watchGroup and made it generic enough to drop into any page with slight modification. Here's a gist, for anyone who's interested:
Love your blog, by the way. Lots of interesting stuff and very prolific!