Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Zach Stepek
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Zach Stepek@zstepek )

What If AngularJS Had A setState() Method Like ReactJS

By Ben Nadel on

After digging into ReactJS for the first time, over the weekend, I was very interested in the way ReactJS thinks about state. State, in React, isn't strictly immutable; but, the framework only reacts to it (no pun intended) when the state is mutated in certain ways - namely, when the .setState() method is called. AngularJS and ReactJS are solving the same problems; but, AngularJS is a bit more "brute force" in this respect. As a thought-experiment, I wanted to see what AngularJS might look like if it also had a .setState() method that was used to indicate whether or not data had actually been mutated.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

Because AngularJS is a bit more brute-force about dirty data checking, there are many aspects of an AngularJS application that will trigger a $digest phase. And, during each $digest phase, all of the scopes are traversed and all of the $watch() bindings are evaluated. For this thought-experiment, since I wanted to be as minimally invasive as possible, I'm not changing the fundamental integration with the $digest; instead, I'm just going to skip over $watch()-binding evaluation unless the scope has been flagged as dirty though a scope.$setstate() method.

The scope.$setState() method is something that I've added to a locally-modified version of AngularJS:

  • // I update the scope, overwriting only the given properties AND only if they
  • // strictly not equal. The point here is to only take note of changes to the scope
  • // that are actually meaningful. And, to make this more efficient by trying to
  • // treat the references as immutable (hence the check for equality).
  • $setState: function( properties ) {
  •  
  • for ( var key in properties ) {
  •  
  • if (
  • properties.hasOwnProperty( key ) &&
  • ( ! this.hasOwnProperty( key ) || ( properties[ key ] !== this[ key ] ) )
  • ) {
  •  
  • this[ key ] = properties[ key ];
  •  
  • this.$$stateIsDirty = true;
  •  
  • }
  •  
  • }
  •  
  • // If this state is dirty, set all children as dirty as well. This will just
  • // make it easier to consume the dirtiness in the scope traversal in the $digest
  • // method.
  • if ( this.$$stateIsDirty ) {
  •  
  • var current = this;
  • var target = this;
  • var next = null;
  •  
  • // NOTE: This logic is borrowed from the $digest method. It does a depth-first
  • // traversal of the scope tree, starting at the current scope.
  • do {
  • current.$$stateIsDirty = true;
  •  
  • if (!(next = (current.$$childHead ||
  • (current !== target && current.$$nextSibling)))) {
  • while (current !== target && !(next = current.$$nextSibling)) {
  • current = current.$parent;
  • }
  • }
  • } while ((current = next));
  •  
  • }
  •  
  • }

The scope.$setState() method accepts a hash of properties. It then loops over the properties and injects them into the current scope if:

  • The property does not yet exist in the scope.
  • The property exists but is a strictly-unequal reference.

The reason for the strict-unequal checking is that we don't want "partially mutated" data to be passed-in. This forces the consumer of the scope to think about the data as generally immutable. Meaning, you can't simply add a value to an array, you have to pass-in an entirely different array reference. This makes the data-diff'ing easier to reason about.

In the $digest phase, I then continue to allow AngularJS to iterate over every scope. But, when an individual scope is examined, I only allow the $watch() bindings to be evaluated if the current scope is flagged as mutated. Overall, the entire diff of my modified AngularJS file looks like this:


 
 
 

 
 Thought experiment: what if AngularJS had a setState() method like ReactJS - local modification. 
 
 
 

Ok, let's take a look at how your AngularJS code might change with this kind of mutation constraint. In the following demo, I have a list of friends and a timer. These two components have different scopes in order to demonstrate that mutations in one scope don't lead to view synchronization in an unrelated scope. Furthermore, in the timer component, I'm only using the .$setState() method part of the time. This is to demonstrate that the view is only synchronized when the .$setState() method is used, even if the $scope object is mutated more often.

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • What If AngularJS Had A setState() Method Like ReactJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body>
  •  
  • <h1>
  • What If AngularJS Had A setState() Method Like ReactJS
  • </h1>
  •  
  • <div ng-controller="ListController">
  •  
  • <h2>
  • You have {{ friends.length }} friends, nice!
  • </h2>
  •  
  • <ul>
  • <li ng-repeat="friend in friends track by friend.id">
  •  
  • {{ friend.name }} ( <a ng-click="deleteFriend( friend )">delete</a> )
  •  
  • </li>
  • </ul>
  •  
  • <form ng-submit="processForm()">
  •  
  • <input type="text" ng-model="form.name" />
  • <input type="submit" value="Add Friend" />
  •  
  • </form>
  •  
  • </div>
  •  
  • <p ng-controller="TimerController">
  •  
  • You've been viewing this for {{ durationInSeconds }} seconds.
  •  
  • </o>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="./angular-modified.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo.
  • angular.module( "Demo", [] );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I control the list of friends.
  • angular.module( "Demo" ).controller(
  • "ListController",
  • function( $scope, friendService ) {
  •  
  • // Set up the initial state of the scope.
  • $scope.$setState({
  • friends: [],
  • form: {
  • name: ""
  • }
  • });
  •  
  • var tempID = 0;
  •  
  • // Load the list of friends.
  • loadRemoteData();
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I delete the given friend.
  • $scope.deleteFriend = function( friend ) {
  •  
  • // Optimistically remove the friend from the list.
  • $scope.friends.splice( $scope.friends.indexOf( friend ), 1 );
  •  
  • // Since mutating the state directly won't do anything, we have to
  • // call setState() AND pass in a totally new reference. Notice that
  • // we are using .slice() to make sure we pass in a different top-level
  • // object reference. If we passed-in the same reference, the state
  • // would NOT be flagged as "dirty".
  • $scope.$setState({
  • friends: $scope.friends.slice()
  • });
  •  
  • friendService.deleteFriend( friend.id );
  •  
  • };
  •  
  •  
  • // I process the new-friend form, adding a new friend to the collection.
  • $scope.processForm = function() {
  •  
  • var placeholder = {
  • id: --tempID,
  • name: $scope.form.name
  • };
  •  
  • // Reset the relevant portions of the state. Notice that we are using
  • // the .concat() method to ensure that we are passing a new top-level
  • // object reference. If we had tried to use .push(), the state would
  • // not be flagged as "dirty."
  • $scope.$setState({
  • friends: $scope.friends.concat( [ placeholder ] ),
  • form: {
  • name: ""
  • }
  • });
  •  
  • friendService.addFriend( placeholder.name )
  • .then( loadRemoteData )
  • ;
  •  
  • };
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I get the current list of friend from the repository.
  • function loadRemoteData() {
  •  
  • friendService.getFriends().then(
  • function handleResolve( friends ) {
  •  
  • $scope.$setState({
  • friends: friends
  • });
  •  
  • }
  • );
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I control the timer interface. This is here to show what happens if you try to
  • // set the $scope values directly, as opposed to going through $setState().
  • angular.module( "Demo" ).controller(
  • "TimerController",
  • function( $scope, $interval ) {
  •  
  • // Set up the initial state.
  • $scope.$setState({
  • durationInSeconds: 0
  • });
  •  
  • var i = 0;
  •  
  • // Every second, we are going to update the $scope; however, we are only
  • // going to use $scope.$setState() a small portion of the time. The
  • // intention here is to demonstrate that the $watch() bindings are only
  • // checked if $setState() is called; other direct calls to the $scope go
  • // unsynchronized (unless a $parent happens to be flagged as dirty).
  • $interval(
  • function() {
  •  
  • // Every 5 seconds, use the appropriate $setState() method.
  • if ( ! ( i++ % 5 ) ) {
  •  
  • $scope.$setState({
  • durationInSeconds: ( $scope.durationInSeconds + 1 )
  • });
  •  
  • // Otherwise, mutate the $scope directly.
  • } else {
  •  
  • $scope.durationInSeconds++;
  •  
  • }
  •  
  • },
  • 1000
  • );
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide a data-access object for friends.
  • angular.module( "Demo" ).factory(
  • "friendService",
  • function( $timeout, $q ) {
  •  
  • // Setup the initial store.
  • var friends = [
  • {
  • id: 1,
  • name: "Sarah"
  • }
  • ];
  •  
  • // Return the public API.
  • return({
  • addFriend: addFriend,
  • deleteFriend: deleteFriend,
  • getFriends: getFriends
  • });
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I add a new friend with the given name. Resolves with new ID.
  • function addFriend( name ) {
  •  
  • var deferred = $q.defer();
  •  
  • $timeout(
  • function() {
  •  
  • var id = ( new Date() ).getTime();
  •  
  • friends.push({
  • id: id,
  • name: name
  • });
  •  
  • deferred.resolve( id );
  •  
  • },
  • 500
  • );
  •  
  • return( deferred.promise );
  •  
  • }
  •  
  •  
  • // I delete the friend with the given ID.
  • function deleteFriend( id ) {
  •  
  • var deferred = $q.defer();
  •  
  • $timeout(
  • function() {
  •  
  • for ( var i = 0, length = friends.length ; i < length ; i++ ) {
  •  
  • if ( friends[ i ].id === id ) {
  •  
  • friends.splice( i, 1 );
  •  
  • return( deferred.resolve() );
  •  
  • }
  •  
  • }
  •  
  • deferred.reject();
  •  
  • },
  • 500
  • );
  •  
  • return( deferred.promise );
  •  
  • }
  •  
  •  
  • // I get all of the friends in the store.
  • function getFriends() {
  •  
  • var deferred = $q.defer();
  •  
  • $timeout(
  • function() {
  •  
  • deferred.resolve( angular.copy( friends ) );
  •  
  • },
  • 500
  • );
  •  
  • return( deferred.promise );
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

Since this is about view synchronization, this code is easier to understand if you watch the video. But, the main take-away is that the $watch() bindings are only evaluated when a scope (or its ancestor) is flagged as dirty. And, if you watch the video, what you'll see is that AngularJS enters the $digest phase a lot; but, it doesn't really do any work unless something has explicitly been flagged as modified.




Reader Comments

Super glad to see that you're digging into React. I've been dipping my toes into React by way of React Native and look forward to your insights!

Reply to this Comment

@Chris,

I've heard some good things about React Native. I've never done any mobile development; but, on the various podcasts, people seem to be very excited about it. Hopefully, I'll be able to get more into React in general.

Reply to this Comment

@Vincent,

Really interesting stuff. I haven't made much time, yet, to dig into the Angular 2.0 stuff. Can you believe that much of my app is still running in 1.0.8 - oh my chickens! We're going to be upgrading to AngularJS 1.4.2 shortly, but it's been a huge struggle trying to keep up with all the code (internally) that is being released.

Also, that article links to another article on building AngularJS apps using Flux, which sounds very provocative. I'll be checking that out as well.

Reply to this Comment

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.