Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Andy Matthews
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Andy Matthews@commadelimited )

Watching And Responding To ngModel Changes In AngularJS

By Ben Nadel on

Last week, a co-worker of mine - Brian Kotch - was writing a custom directive and asked me about the best approach to use when responding to an ngModel change in AngularJS. He wasn't creating an input component that managed the ngModel value; rather, he was creating a sort of helper directive for textarea rendering. I know there are several ways to watch and manage ngModel changes, but I didn't have a good post to point him to. So, I wanted to write a quick summary of how you can listen for ngModel changes.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

First, let's just consider what ngModel is. At its very foundation, it's an attribute directive that watches a scope expression. Fundamentally, this is no different than any other attribute directive that watches an expression (like ngIf or ngSwitch). It just so happens that ngModel and the ngModel consumers also implement two-way data-binding, interact with form controllers and, have special CSS classes that they use. But, that's all implementation detail.

The point is, since ngModel is just an attribute directive that watches a scope expression, the easiest way to respond to an ngModel change is to also watch the same scope expression:

scope.$watch( attributes.ngModel, changeHandler );

This way, every time the scope value changes, your change handler will also be invoked. And, since the underlying ngModelController is instantiated during the pre-linking phase - and will wire up its $watch() binding before yours - there's also a very good chance that the DOM (Document Object Model) will be updated by the time your change handler is invoked. But, that's an implementation detail that may vary depending on the ngModel consumer.

Depending on what you are trying to accomplish, you can also use the $viewChangeListeners collection exposed by the ngModelController. The $viewChangeListeners is just a collection of callbacks that will be invoked any time the input component changes and needs to be synchronized into the ngModel expression. To be clear, however, these callbacks are only invoked when the ngModel needs to be updated. If the ngModel value is changed by some other means and the input control needs to be synchronized, these callbacks will not be invoked.

  • Input changes --> ngModel ($viewChangeListeners callbacks invoked)
  • ngModel changes --> Input ($viewChangeListeners callbacks are not invoked)

These change-hooks seems to be more geared for the ngModel consumer to react to changes without having to wire up an additional $watch() expression. Something more akin to an additional $render() binding.

And, of course, if you're actually creating an ngModel consumer - meaning, you're creating an input control component - then, you can require the ngModelController and hook into the $render() method, $parsers, $formatters, and the rest of the input control workflow. That's a pretty big topic unto itself, though, and not one that I am all that versed in.

That said, I tried to put together a small example that ties these three approaches together. I created a toggle widget, which is an input control and ngModel consumer. Then, I created two additional attribute directives that listen for ngModel changes using a scope $watch() and the $viewChangeListeners collection, respectively.

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Watching ngModel Changes In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController as vm">
  •  
  • <h1>
  • Watching ngModel Changes In AngularJS
  • </h1>
  •  
  • <bn-toggle
  • ng-model="vm.isOn"
  • on-text="On"
  • off-text="Off"
  • bn-model-watch
  • bn-model-change-listener>
  • </bn-toggle>
  •  
  • <p>
  • <a ng-click="vm.toggleState()">Toggle</a> : {{ vm.isOn }}
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.4.7.min.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo.
  • var app = angular.module( "Demo", [] );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I control the root of the application.
  • angular.module( "Demo" ).controller(
  • "AppController",
  • function AppController( $scope ) {
  •  
  • var vm = this;
  •  
  • // I determine if the toggle is on.
  • vm.isOn = true;
  •  
  • // Expose public methods.
  • vm.toggleState = toggleState;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I set the state of the toggle model.
  • function toggleState() {
  •  
  • vm.isOn = ! vm.isOn;
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I define the toggle "component directive".
  • angular.module( "Demo" ).directive(
  • "bnToggle",
  • function bnToggleDirective() {
  •  
  • // Return the directive configuration object.
  • return({
  • link: link,
  • require: "ngModel",
  • restrict: "E",
  • scope: {
  • onText: "@",
  • offText: "@"
  • },
  • template:
  • `
  • <div class="label-container">
  • <div class="on-label" ng-class="{ on: toggleIsOn }">
  • {{ onText }}
  • </div>
  • <div class="off-label" ng-class="{ on: ! toggleIsOn }">
  • {{ offText }}
  • </div>
  • </div>
  • `
  • });
  •  
  •  
  • // I bind the JavaScript events to the view-model.
  • function link( scope, element, attributes, ngModelController ) {
  •  
  • // I am used to define the internal state of the toggle (which will
  • // be synchronized with the ngModel value).
  • scope.toggleIsOn = false;
  •  
  • // Add a ngModel formatter to ensure that the view value is always
  • // consumable (not really necessary for this demo).
  • ngModelController.$formatters.push( formatModelValue );
  •  
  • // Wire up the render, which will be called when the $viewModel value
  • // has been updated programmatically and the component needs to be
  • // updated to reflect that change.
  • ngModelController.$render = renderViewValue;
  •  
  • element.on( "click", handleClick );
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I format the incoming model value to make sure it is consumable
  • // by the view (modeValue --> viewValue).
  • // --
  • // NOTE: This isn't really required for this demo; but, I wanted to
  • // include it to hint that the ngModel life-cycle is more robust than
  • // it might seem at first.
  • function formatModelValue( modelValue ) {
  •  
  • return( !! modelValue );
  •  
  • }
  •  
  •  
  • // I handle the click event on the component and update the viewValue
  • // to reflect the intent of the click.
  • function handleClick( event ) {
  •  
  • var target = angular.element( event.target );
  •  
  • // Tell AngularJS that we changed something.
  • scope.$apply(
  • function changeViewModel() {
  •  
  • // Update the internal component state based on the click.
  • scope.toggleIsOn = target.hasClass( "on-label" );
  •  
  • // Tell the ngModelController about the change.
  • // --
  • // NOTE: This will not trigger a $render() call, which is
  • // why it is important that internal state of the component
  • // is updated directly (above).
  • ngModelController.$setViewValue( scope.toggleIsOn );
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I update the rendering of the component based on the ngModel value.
  • function renderViewValue() {
  •  
  • // NOTE: We know that this will be a strict Boolean based on the
  • // formatter that we pushed onto the stack above.
  • scope.toggleIsOn = ngModelController.$viewValue;
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I log the model value based on the $watch() binding.
  • angular.module( "Demo" ).directive(
  • "bnModelWatch",
  • function bnModelWatchDirective() {
  •  
  • // Return the directive configuration object.
  • return({
  • link: link,
  • restrict: "A"
  • });
  •  
  •  
  • // I bind the JavaScript events to the view-model.
  • function link( scope, element, attributes ) {
  •  
  • // In this approach, we're just going to watch the ngModel expression
  • // for changes in the same way we would watch any other scope-based
  • // value for changes.
  • scope.$watch(
  • attributes.ngModel,
  • function( newValue, oldValue ) {
  •  
  • console.log( "ngModel value changed (A):", newValue );
  •  
  • }
  • );
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I log the model value based on a $viewChangeListeners binding.
  • angular.module( "Demo" ).directive(
  • "bnModelChangeListener",
  • function bnModelChangeListenerDirective() {
  •  
  • // Return the directive configuration object.
  • // --
  • // NOTE: In order to use this approach, we have to require the
  • // ngModelController.
  • return({
  • link: link,
  • require: "ngModel",
  • restrict: "A"
  • });
  •  
  •  
  • // I bind the JavaScript events to the view-model.
  • function link( scope, element, attributes, ngModelController ) {
  •  
  • // In this approach, we're just going to push a listener onto the
  • // change-listener collection of the ngModelController. This will get
  • // called whenever the $viewValue has changed and has resulted in a
  • // change of the $modelValue.
  • // --
  • // NOTE: If the ngModel value is changed programmtically, this will
  • // not be triggered because, in that case, the viewValue - while
  • // changing - is simply being synchronized with the ngModel value.
  • ngModelController.$viewChangeListeners.push(
  • function handleNgModelChange() {
  •  
  • console.log( "ngModel value changed (B):", ngModelController.$viewValue );
  •  
  • }
  • );
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, the $watch() binding on the ngModel is the most straightforward approach if you just want to watch the ngModel value. But, if you're creating an ngModel consumer, the ngModelController gives you a tremendous amount of control over how changes are propagated into and out of your input control.

When we run this code, change the ngModel value externally, and then try to interact with the widget, we get the following output:


 
 
 

 
 Watching and responding to ngModel changes in AngularJS. 
 
 
 

When it comes to watching and responding to ngModel changes in AngularJS, there's no "best" way. Rather, there are several ways that work; and, the right way really depends on what you are trying to do. If you're creating an input control component, you probably need to use the ngModelController and the two-way data-binding workflow. But, if you're just trying to observe changes on the ngModel value, you're probably fine just using a scope $watch() binding.




Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.