Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Kev McCabe
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Kev McCabe@bigmadkev )

Experimenting With ngModel And ngModelController In AngularJS

By Ben Nadel on

For the last few years, I've happily consumed the ngModel directive, in AngularJS; but, I've never really thought much about what it does - it just worked. But, ngModel does a lot - way more than I actually use it for, currently. After listening to the Form Validation episode of Adventures in AngularJS, with Kent C. Dodds, I was inspired to try to learn more about it.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

At the most basic level, the ngModel directive provides a two-way data-binding mechanism that abstracts the connection between your View-Model and various User Interface (UI) controls. So, for example, it knows how to bind your View-Model to something like an input[type=text]; and, conversely, it knows how to update the Input value when your View-Model changes.

But, this two-way data binding could be accomplished with some straightforward(ish) $watch() and $apply() functions. It's not the two-way data binding that makes ngModel so powerful. As Kent pointed out in his podcast interview, the cool stuff - the powerful stuff - is all the peripheral functionality and the interaction with Form containers. In addition to the two-way data binding, the ngModel directive, the ngModelController, the Form directive, and the FormController all work together to provide a robust feature set related to data entry, formatting, and input validation.

I'm only looking into this for the first time, so I can't really go into much more detail than that. As a first step, though, I wanted to try and create my own Input Control (in the generic sense) that uses ngModel to facilitate two-way data-binding.

The control I'm creating is like a Select. However, rather than providing a dropdown menu of options, you need to click the input in order to cycle through the available values, one item per click. Is this a good User Experience (UX)? Absolutely not. But, this felt like a nice low-barrier-of-entry to examine a few different aspects of the ngModel / ngModelController workflow.

My Directive, bnListToggle, consumes the ngModel directive and requires two additional values: the list over which to iterate and the expression used to render the currently selected value.

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Experimenting With ngModel And ngModelController In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Experimenting With ngModel And ngModelController In AngularJS
  • </h1>
  •  
  • <!--
  • We're using the FORM directive in this demo as the ngModel usage will
  • automatically interact with the FormController to do things like apply an
  • "ng-dirty" class to the form when our list-toggle directive is consumed by
  • the user.
  • --
  • NOTE: By naming the form, an instance of the FormController will automatically
  • be applied to the current Scope using the given name (ie, $scope.myForm).
  • -->
  • <form name="myForm">
  •  
  • <!--
  • Using this directive to iterate over the given list when clicked, one item
  • per click. When moving onto the next item, the ngModel value is updated to
  • reflect the current selection; this will, in turn, interact automatically
  • with the parent Form directive.
  • --
  • * bnListToggle: The expression used to access the target list.
  • * text: The expression used to render the selected value.
  • -->
  • <div
  • ng-model="selectedFriend"
  • bn-list-toggle="friends"
  • text="( id + '. ' + name )">
  • </div>
  •  
  • <p>
  • <!-- Using this to alter the view-model externally to the directive. -->
  • <a ng-click="selectFirstFriend()">Select First Friend</a>
  •  
  • &mdash;
  •  
  • <!-- Using this to erase a friend selection. -->
  • <a ng-click="selectNullFriend()">Select NULL Friend</a>
  • </p>
  •  
  • </form>
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.3.6.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.
  • app.controller(
  • "AppController",
  • function( $scope ) {
  •  
  • // I hold the list of friends over which we will be iterating in the
  • // ngModel control.
  • $scope.friends = [
  • {
  • id: 1,
  • name: "Kim"
  • },
  • {
  • id: 2,
  • name: "Sarah"
  • },
  • {
  • id: 3,
  • name: "Joanna"
  • },
  • {
  • id: 4,
  • name: "Tricia"
  • },
  • {
  • id: 5,
  • name: "Anna"
  • }
  • ];
  •  
  • // By default, select the first friend.
  • $scope.selectedFriend = $scope.friends[ 0 ];
  •  
  • // For debugging purposes, let's observe changes in the selected friend.
  • // This way, we can see when / how the view-model is changed by the two-way
  • // ngModel binding.
  • $scope.$watch(
  • "selectedFriend",
  • function handleModelChange( newValue, oldValue ) {
  •  
  • // Ignore null values as we can't render them (using .name).
  • if ( ! newValue || ! oldValue ) {
  •  
  • return( console.info( "Null value change:", newValue ) );
  •  
  • }
  •  
  • console.log( "Selected friend changed from [", newValue.name, "-->", oldValue.name, "]." );
  •  
  • }
  • );
  •  
  • // Since we are using a named-form, we now have a copy of the
  • // FormController on the scope. As such, we can watch for changes in the
  • // form state and respond. In this case, we're logging when the form loses
  • // its pristine nature.
  • $scope.$watch(
  • "myForm.$dirty",
  • function handleModelChange( newValue ) {
  •  
  • if ( newValue ) {
  •  
  • console.warn( "Form is so dirty." );
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I select the first friend in the list. This demonstrates that the
  • // ngModel binding can react to external changes as well as internal
  • // changes triggered by the control.
  • $scope.selectFirstFriend = function() {
  •  
  • console.info( "Selecting first friend." );
  •  
  • $scope.selectedFriend = $scope.friends[ 0 ];
  •  
  • };
  •  
  •  
  • // I remove the current friend selection. This demonstrates how the
  • // control can react to changes in the model that aren't necessarily
  • // accounted for in the list-context.
  • $scope.selectNullFriend = function() {
  •  
  • console.info( "Selecting NULL friend." );
  •  
  • $scope.selectedFriend = null;
  •  
  • };
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I provide an input control that iterates though a list, selecting the current
  • // value (into ngModel). When the user clicks on the control, the next list item
  • // is selected (loops back to index 0 when end of list is reached).
  • app.directive(
  • "bnListToggle",
  • function( $parse ) {
  •  
  • // Return the directive configuration. Notice that we are requiring the
  • // ngModel controller to be passed into our linking function.
  • return({
  • link: link,
  • require: "ngModel",
  • restrict: "A"
  • });
  •  
  •  
  • // I bind the JavaScript events to the local scope.
  • function link( scope, element, attributes, ngModelController ) {
  •  
  • // Validate directive attributes.
  • if ( ! attributes.bnListToggle ) {
  •  
  • throw( new Error( "bnListToggle requires list over which to iterate." ) );
  •  
  • }
  •  
  • // Validate directive attributes.
  • if ( ! attributes.text ) {
  •  
  • throw( new Error( "bnListToggle requires a text expression." ) );
  •  
  • }
  •  
  •  
  • // I provide a method to access the list for the control.
  • var listAccessor = $parse( attributes.bnListToggle );
  •  
  • // When the value of the control is selected, we need a way to
  • // render it in the HTML. We'll use the text attribute as an
  • // expression to evaluate in the context of the selected value.
  • var textAccessor = $parse( attributes.text );
  •  
  • // When the ngModel directive updates the bi-directionally-bound
  • // value, our control needs to be notified so that we can update
  • // the HTML. By providing a $render() method, we can hook into the
  • // ngModel update.
  • ngModelController.$render = renderCurrentValue;
  •  
  • // When the use clicks on the toggle, we need to move onto the next
  • // item in the list.
  • element.on(
  • "click",
  • function handleClickEvent( event ) {
  •  
  • // Since we are changing the View-Model, we have to use
  • // $apply() in order to let AngularJS know that a change has
  • // occurred.
  • scope.$apply( selectNextValue );
  •  
  • }
  • );
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I use the current ngModel value to the render the HTML.
  • function renderCurrentValue() {
  •  
  • // If the current value is empty, clear the control.
  • // --
  • // NOTE: Each Control can provide its own $isEmpty() override;
  • // however, the default implementation checks for null / undefined
  • // values, which is sufficient for our use-case.
  • if ( ngModelController.$isEmpty( ngModelController.$viewValue ) ) {
  •  
  • return( element.html( "<em>Nothing selected</em>" ) );
  •  
  • }
  •  
  • // If the ngModel has a non-empty value, we can build the HTML for
  • // the control by evaluating the text-accessor in the context of
  • // the selected value.
  • element.html( textAccessor( ngModelController.$viewValue ) );
  •  
  • };
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I get the next list item given the current list item.
  • function getNextListValue( list, currentValue ) {
  •  
  • // If we have no list, then there is no next item.
  • if ( ! list || ! list.length ) {
  •  
  • return( null );
  •  
  • }
  •  
  • var currentIndex = list.indexOf( currentValue );
  •  
  • // NOTE: If -1, becomes 0, which is OK.
  • var nextIndex = ( currentIndex + 1 );
  •  
  • // Check bounds, loop around if necessary.
  • if ( nextIndex >= list.length ) {
  •  
  • nextIndex = 0;
  •  
  • }
  •  
  • return( list[ nextIndex ] );
  •  
  • }
  •  
  •  
  • // I select the next value in the list, based on the current state of
  • // the ngModel binding.
  • function selectNextValue() {
  •  
  • // Gather the list from the current scope.
  • var list = listAccessor( scope );
  •  
  • // Get the current ngModel binding.
  • var currentValue = ngModelController.$viewValue;
  •  
  • // Get the next value. May return NULL if the list is empty.
  • var nextValue = getNextListValue( list, currentValue );
  •  
  • // Tell the ngModel directive to update the value to reflect the
  • // next item in the list.
  • // --
  • // NOTE: The AngualrJS documentation suggests passing-in a COPY
  • // of this value (if its an Object) since the ngModel directive
  • // compares references internally (not deep-copy comparisons).
  • // However, I believe that is only required in edge-cases; as
  • // long as we are using direct "view-model" references (as we
  • // are in this approach), then I see no problem with passing the
  • // reference directly into $setViewValue().
  • ngModelController.$setViewValue( nextValue );
  •  
  • // Calling $setViewModel() does not implicitly trigger a call to
  • // $render(). As such, we have to explicitly re-render the newly-
  • // selected value.
  • renderCurrentValue();
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

At the most basic level, an ngModel consumer has to do two things: override the $render() method and call $setViewValue().

The ngModelController.$render() method is called implicitly when the View-Model has been updated and AngularJS needs your input control to update its own state to mirror the new View-Model value. This method takes no arguments. And, when it's called, you are expected to pull the current View-Model value from ngModelController.$viewValue (to help render the Control HTML).

When the View-Model needs to change, based on a unser's interaction with your control, you have to call the ngModelController.$setViewValue() method and pass-in the new value provided by your control. There are few caveats to this method:

First, it doesn't trigger a $digest automatically. So, just as with any directive, you need to wrap this update in an $apply() call so that AngularJS will know that the View-Model has been updated.

Second, it doesn't implicitly call the $render() method. $render() only gets invoked when the View-Model is changed externally to the ngModel consumer. As such, you will need explicitly update the rendering of your Control after you set the $viewValue.

Third, I found this portion of the ngModelController to be very confusing and downright misleading:

If the new value is an object (rather than a string or a number), we should make a copy of the object before passing it to $setViewValue. This is because ngModel does not perform a deep watch of objects, it only looks for a change of identity. If you only change the property of the object then ngModel will not realise that the object has changed and will not invoke the $parsers and $validators pipelines.

Unless I'm missing something, this description makes no sense. And, if you take a step back, you can see why it's misguided. Given a list of values, taken from the Scope, I'm trying to find the selected one when rendering the Control. If I'm passing a COPY of the selected one back into the View-Model, then I'll never be able to find its reference in the list during the next iteration.

I think this advice would only make sense if the selected value originated entirely from within the Control, rather than from within the Scope. That said, I don't have enough experience with ngModel to know what kind of a use-case that is; it seems like you'd always want to pull an object-based $viewValue out of the Scope.

The other thing you can see in this demo is the implicit interaction with the parent Form directive and the FormController. When we give the form a name, the related FormController is automatically applied to the scope, where we can observe changes to its state. We can use this controller to present validation problems and error messages and more - I'm don't really known the full extent of this interaction yet.

And, one thing that you can't see in the code is the fact that CSS classes are automatically applied to both the input control and the form. Specifically, AngularJS is applying an "ng-dirty" class to both the form and the input control when I change the ngModel binding. This is just one of many CSS classes that automatically get applied during the whole ngModel / form lifecycle.

For a long time, I've known that you could create your own custom ngModel controls. But, I was always quite intimidated by the seeming complexity of it all. Now that I know you only have to implement a little code, it seems rather interesting. It's definitely a feature of AngularJS that I'll be digging into more in the future.




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.