Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Ralph Whitbeck
Ben Nadel at the jQuery Conference 2010 (Boston, MA) with: Ralph Whitbeck@RedWolves )

Stateless Filters Don't Apply To Objects, Arrays, or ngRepeat In AngularJS 1.3

By Ben Nadel on

In the past, I have talked about my dislike for Filters in AngularJS. Not only do they have a performance concern, they also make it harder to manage the state of the View as filtering is applied to collections (ex, ngRepeat). The other day, after some filter-based frustration (with someone else's code) I tweeted:


 
 
 

 
Friends don't let friends use filters on ngRepeat in AngularJS. 
 
 
 

In response to this, Joel Hooks mentioned that AngularJS 1.3 now has "memoized filters". These are optimized such that they only get reapplied if the inputs to the filter have changed. This sounds good; but, upon further exploration, it looks like the memoization (ie, caching) of filters is only applied to simple inputs. In other works, stateless filters don't apply to Objects, Arrays, or the ngRepeat directive.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

After pouring through the source code for two days, it looks like stateless filters don't pertain to Objects, Arrays, or the ngRepeat directive. If a filter takes an Object or an Array as an input, the "dirty check" operation, that compares the previous inputs to the current inputs, will always flag the new inputs as "dirty". As such, the filter will always be reapplied in each digest.

With the ngRepeat directive, stateless filters are ignored for a different reason. If you dig into the ngRepeat source code, the directive ultimately boils down to the Scope.$watchCollection() method. And, if you dig into the $watchCollection() method, you will see that its interceptor is flagged as $stateful. As such, when the ngRepeat expression is being parsed, the stateless-input $$watchDelegate is never promoted. And, as such, the ngRepeat filter will always be reapplied in each digest.

There's nothing in the documentation that talks about this (that I could find). And digging through the source code is a bit of Herculean task. The easiest way to see this is to simply log the native filter() activity and then try to use it with an ngRepeat:

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Stateless Filters Don't Apply To Objects, Arrays, or ngRepeat In AngularJS 1.3
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Stateless Filters Don't Apply To Objects, Arrays, or ngRepeat In AngularJS 1.3
  • </h1>
  •  
  • <ul>
  • <!--
  • For this ngRepeat directive, we'll be using the native filter() filter.
  • This will limit the collection to objects that have a value that matches
  • the given input query.
  • -->
  • <li ng-repeat=" friend in friends | filter:query track by friend.id">
  •  
  • {{ friend.name }}
  •  
  • </li>
  • </ul>
  •  
  • <p>
  • <!--
  • This does nothing but trigger a new digest (implicitly). We're doing this
  • to see if the new digest triggers filter activity above.
  • -->
  • <a ng-click="triggerDigest()">Trigger digest</a>
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="./angular-1.3.9.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, $timeout ) {
  •  
  • // We'll be filtering on values that match "ah".
  • $scope.query = "ah";
  •  
  • // Start out with our default collection. We'll be searching for values
  • // that match the query "ah". To start, only "Sarah" will match.
  • $scope.friends = [
  • {
  • id: 1,
  • name: "Tricia",
  • favoriteThings: [ "Documentaries", "Cats" ]
  • },
  • {
  • id: 2,
  • name: "Sarah",
  • favoriteThings: [ "Action Movies", "Cats" ]
  • },
  • {
  • id: 3,
  • name: "Joanna",
  • favoriteThings: [ "Romantic Comedies", "Dogs" ]
  • }
  • ];
  •  
  •  
  • // After some time, let's alter Tricia's name to end with "ah". If the
  • // filter is being constantly applied, then this will cause Trici"ah" to
  • // show up in the filtered list.
  • $timeout(
  • function() {
  •  
  • console.info( "Changing name. . . . Triciah" );
  •  
  • $scope.friends[ 0 ].name = "Triciah";
  •  
  • },
  • 1000
  • );
  •  
  •  
  • // After some time, let's alter Joanna's name to end with "ah". If the
  • // filter is being constantly applied, then this will cause Joann"ah" to
  • // show up in the filtered list.
  • $timeout(
  • function() {
  •  
  • console.info( "Changing name. . . . Joannah" );
  •  
  • $scope.friends[ 2 ].name = "Joannah";
  •  
  • },
  • 3000
  • );
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // The ng-click directive will implicitly trigger a new digest.
  • $scope.triggerDigest = function() {
  •  
  • console.info( "Triggering digest. . . ." );
  •  
  • };
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, I'm using the native filter() function to filter the ngRepeat list against the query, "ah". At first, this will only match one item in the list. Then, as the $timeout() service executes, it will match two items and then, eventually, all three items.

With each $timeout() we get a new $digest. And, with each $digest, we can see the logging the of the filter activity:

Executing deep-compare: [ 1 ] vs. [ ah ]
Executing deep-compare: [ tricia ] vs. [ ah ]
Executing deep-compare: [ documentaries ] vs. [ ah ]
Executing deep-compare: [ cats ] vs. [ ah ]
Executing deep-compare: [ 2 ] vs. [ ah ]
Executing deep-compare: [ sarah ] vs. [ ah ]
Executing deep-compare: [ 3 ] vs. [ ah ]
Executing deep-compare: [ joanna ] vs. [ ah ]
Executing deep-compare: [ romantic comedies ] vs. [ ah ]
Executing deep-compare: [ dogs ] vs. [ ah ]
Executing deep-compare: [ 1 ] vs. [ ah ]
Executing deep-compare: [ tricia ] vs. [ ah ]
Executing deep-compare: [ documentaries ] vs. [ ah ]
Executing deep-compare: [ cats ] vs. [ ah ]
Executing deep-compare: [ 2 ] vs. [ ah ]
Executing deep-compare: [ sarah ] vs. [ ah ]
Executing deep-compare: [ 3 ] vs. [ ah ]
Executing deep-compare: [ joanna ] vs. [ ah ]
Executing deep-compare: [ romantic comedies ] vs. [ ah ]
Executing deep-compare: [ dogs ] vs. [ ah ]
Changing name. . . . Triciah
Executing deep-compare: [ 1 ] vs. [ ah ]
Executing deep-compare: [ triciah ] vs. [ ah ]
Executing deep-compare: [ 2 ] vs. [ ah ]
Executing deep-compare: [ sarah ] vs. [ ah ]
Executing deep-compare: [ 3 ] vs. [ ah ]
Executing deep-compare: [ joanna ] vs. [ ah ]
Executing deep-compare: [ romantic comedies ] vs. [ ah ]
Executing deep-compare: [ dogs ] vs. [ ah ]
Executing deep-compare: [ 1 ] vs. [ ah ]
Executing deep-compare: [ triciah ] vs. [ ah ]
Executing deep-compare: [ 2 ] vs. [ ah ]
Executing deep-compare: [ sarah ] vs. [ ah ]
Executing deep-compare: [ 3 ] vs. [ ah ]
Executing deep-compare: [ joanna ] vs. [ ah ]
Executing deep-compare: [ romantic comedies ] vs. [ ah ]
Executing deep-compare: [ dogs ] vs. [ ah ]
Changing name. . . . Joannah
Executing deep-compare: [ 1 ] vs. [ ah ]
Executing deep-compare: [ triciah ] vs. [ ah ]
Executing deep-compare: [ 2 ] vs. [ ah ]
Executing deep-compare: [ sarah ] vs. [ ah ]
Executing deep-compare: [ 3 ] vs. [ ah ]
Executing deep-compare: [ joannah ] vs. [ ah ]
Executing deep-compare: [ 1 ] vs. [ ah ]
Executing deep-compare: [ triciah ] vs. [ ah ]
Executing deep-compare: [ 2 ] vs. [ ah ]
Executing deep-compare: [ sarah ] vs. [ ah ]
Executing deep-compare: [ 3 ] vs. [ ah ]
Executing deep-compare: [ joannah ] vs. [ ah ]
Triggering digest. . . .
Executing deep-compare: [ 1 ] vs. [ ah ]
Executing deep-compare: [ triciah ] vs. [ ah ]
Executing deep-compare: [ 2 ] vs. [ ah ]
Executing deep-compare: [ sarah ] vs. [ ah ]
Executing deep-compare: [ 3 ] vs. [ ah ]
Executing deep-compare: [ joannah ] vs. [ ah ]

As you can see, the filter() filter was being applied to the collection over and over again.

While I don't have the code outlined in this demo, the same can be seen if you create a custom filter and apply it to an Object. The filter will get called with each digest.

I was able to see the memoization of stateless filters in AngularJS 1.3; but, only with filters whose arguments contain nothing but simple values. This would apply to filters like "date" and "currency", as they only deal with Strings and Numbers. This is still a performance benefit; but, make no mistake: this is not a panacea for filter-degraded performance.




Reader Comments

@Clark,

I took a look at that post. If you try editing the Plnkr associated with the code sample and then put a `console.log()` into the filter, right before the call to array.slice(), you will see that it is getting called with every digest. Only, there's not much on the page that triggers a digest, so you only see it log twice.

If you then edit the Controller add an interval:

$interval( angular.noop, 500 );

... then, you will start to see it log a lot to the console. The filter just happens to be very fast. All it's really doing is calling the .slice() method on the array. So, that must be pretty fast as well. But, it's definitely getting called on every digest.

Reply to this Comment

@Ben,

Yep i ran exactly that after i finished my transit to work. Sorry for not posting back here. Your analysis is definitely correct.

But sigh, unfortunate that article now ranks 5th for a google search of 'stateful filters angularjs', which means people will now use that as information.

I'll post a comment to that post telling them to look here.

Reply to this Comment

Ben is rocks:) by the way meaning of "ben" in turkish is "i" || 'my':)

so, best and easy way is watching just array element count, then build filtered new array for ng-repead??

Reply to this Comment

@Tolga,

Yeah, that's exactly what I would do. Usually the filtering only needs to be updated from a small set of user initiated events - like selecting an option in a dropdown menu or typing into an input field. As such, I'll bind an event handler / $watch handler that rebuilds a filtered collection.

Now, with AngularJS 1.3, it looks like you can even have the ngModel updates get debounced so that a change-event doesn't necessarily trigger for every single key-press; though, I haven't tried that myself yet.

Reply to this Comment

I'm wondering when is on page instant filtering necessary?

If the list is small, I would imagine filtering to be quite fast, even if it does it 10 times.

If the list is large, *multiple pages, then it makes sense to reload data from server and request server to filter it.

"have a performance concern," ... how slow is it?

Reply to this Comment

@Ben,

"Yeah, that's exactly what I would do. Usually the filtering only needs to be updated from a small set of user initiated events - like selecting an option in a dropdown menu or typing into an input field. As such, I'll bind an event handler / $watch handler that rebuilds a filtered collection."

That seems like a great idea to get unnecessary and costly '$watch'ers out of the $digest loop in order to improve performance. Does this mean, for instance, you are '$watch'ing inside your controller explicitly for, say, 'searchText' from an input field before filtering your data array for matching data? If so, would you say that what you are ultimately doing is putting a less costly '$watch'er (e.g. searchTextOld === searchTextNew) in front of a more costly one? Also, do you have a way of organizing all of those 'gating' '$watch'ers that respond to user initiated events prior to dirty checking filter expression? I'm sorta just wondering how to begin to think about the world in which I take direct control over the piping of user initiated events to filtered collection rebuilds. I would love to see an example of how you approach this if you ever have the time!

Reply to this Comment

Ben,

Great article. I am in a javascript bootcamp and am using the mean stack to build a web app for a pretend superstore. I have partnered with a friend and we are rockin! One question for you - we are to the point where we have employees and products schemas and inside them we have department object. I am advocating having an employees array and products array inside our departments schema - however, my partner would like to explore using ng-repeat and filters (which is exactly what you warn about above). What advice or direction would you head? Thank again!

James

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.