51

The underscore library provides a debounce function that prevents multiple calls to a function within a set period of time. Their version makes use of setTimeout.

How could we do this in pure AngularJS code?

Moreover, can we make use of $q style promises to retrieve the return value from the called function after the debounce period?

Pete BD
  • 10,151
  • 3
  • 31
  • 30
  • 1
    A side note: You most probably asked this because you have too many requests firing when you only want one to fire. I have been facing this issue for the last 3 days and with a dozen attempts of restructuring my code and reading the documentation, I have achieved what I wanted without enforcing setTimeout. I'm generalizing here but see if you can approach your issue the same way. – Aziz Alfoudari Nov 10 '12 at 07:06
  • Very enigmatic comment! I would be interested to see what you came up with. I agree that this should not be used just to deal with too many watchers firing too often. It wasn't actually my issue but one that was put in the mailing list . – Pete BD Nov 11 '12 at 07:29
  • 3
    Where I think it could be useful is where you have something happening due to user input like an async lookup on a server to autocomplete an input box. You might only want the lookup to happen when the user stops typing for a while. – Pete BD Nov 11 '12 at 07:32

6 Answers6

94

Here is a working example of such a service: http://plnkr.co/edit/fJwRER?p=preview. It creates a $q deferred object that will be resolved when the debounced function is finally called.

Each time the debounce function is called the promise to the next call of the inner function is returned.

// Create an AngularJS service called debounce
app.factory('debounce', ['$timeout','$q', function($timeout, $q) {
  // The service is actually this function, which we call with the func
  // that should be debounced and how long to wait in between calls
  return function debounce(func, wait, immediate) {
    var timeout;
    // Create a deferred object that will be resolved when we need to
    // actually call the func
    var deferred = $q.defer();
    return function() {
      var context = this, args = arguments;
      var later = function() {
        timeout = null;
        if(!immediate) {
          deferred.resolve(func.apply(context, args));
          deferred = $q.defer();
        }
      };
      var callNow = immediate && !timeout;
      if ( timeout ) {
        $timeout.cancel(timeout);
      }
      timeout = $timeout(later, wait);
      if (callNow) {
        deferred.resolve(func.apply(context,args));
        deferred = $q.defer();
      }
      return deferred.promise;
    };
  };
}]);

You get the return value from the debounced function by using the then method on the promise.

$scope.addMsg = function(msg) {
    console.log('addMsg called with', msg);
    return msg;
};

$scope.addMsgDebounced = debounce($scope.addMsg, 2000, false);

$scope.logReturn = function(msg) {
    console.log('logReturn called with', msg);
    var promise = $scope.addMsgDebounced(msg);
    promise.then(function(msg) {
        console.log('Promise resolved with', msg);
    });
};

If you call logReturn multiple times in quick succession you will see the logReturn call logged over and over but only one addMsg call logged.

Gian Marco
  • 22,140
  • 8
  • 55
  • 44
Pete BD
  • 10,151
  • 3
  • 31
  • 30
  • 1
    Out of curiosity, do you find the returned promises of any use? For instance in the case of an autocomplete, it would be nice to have a single promise that tells me what the user finally typed, but not a promise per keystroke. I'm starting to feel like you can get away with promises, and you can get away with debouncing + callback, but not both. – Roy Truelove Feb 07 '13 at 22:44
  • This is what you do get! The promise you receive is the result of finally calling the inner function. You'll notice that in the example, "Resolved: ..." is only written to the console once per debounce and that "value" will be the return value from $scope.addMsg() – Pete BD Feb 09 '13 at 19:31
  • When you say "once per debounce" do you mean once per click? eg when I click 'Add Message (logged)' 10 times, I get 10 log messages, and then 2 seconds later I get 10 resolved messages. I was trying to figure out a way to get only 1 resolved message, but.. alas. – Roy Truelove Feb 10 '13 at 14:16
  • Thanks Pete, let me know if you find a workaround - I wasn't able to :( – Roy Truelove Feb 11 '13 at 21:47
  • 3
    Actually this is not a bug in the debounce service. It returns the same promise on every call until the timeout completes. The trouble is that the Add Message (logged) was doing a call to then on the same promise over and over again on each call. So when the single promise resolved, the numerous then handlers were being run. Here is a better demo that tracks the promise and only adds one handler per promise: http://plnkr.co/edit/afX9v0?p=preview – Pete BD Feb 12 '13 at 10:15
  • Gotcha - so the important thing is to always use the *last* promise, not every one you get. Thanks again! – Roy Truelove Feb 13 '13 at 16:47
  • 1
    Strictly the same promise is returned for each resolve. So you can take the first, middle or last promise between each resolve. – Pete BD Feb 14 '13 at 17:44
  • 1
    Nice solution. Quick usage note: remember to define your function to debounce, before you define your debouncer: i.e. `$scope.doSomething = function() {}` first, then `$scope.doSomethingDebounced= debounce($scope.doSomething , 500, false);` – Walter Stabosz Apr 02 '13 at 14:31
  • Fantastic! Worked great with the function I'm using to return results to my AngularUI Bootstrap Typeahead. Thanks! – Mike Haas Apr 10 '13 at 09:15
  • I was off by one call using underscore built in! – programaths Jul 15 '13 at 12:11
  • Found a bug when converting this to use $.Deferred. You're returning the wrong promise when callNow is true: http://bit.ly/198vzOz – xn. Jul 19 '13 at 23:56
  • Wonder if this begs the question - should a debounced function *ever* return anything? Should it assume set-and-forget? – Roy Truelove Aug 05 '13 at 17:14
  • 1
    Could you walk through the function with comments, I'm trying to understand why you reassign deferred with "deferred = $q.defer();" after you resolve the deferred. – CMCDragonkai Aug 20 '13 at 11:04
  • 2
    there's a typo on the very last line: }); should be }]); unless I missed something ... worked for me, thanks for this! – Blake Miller Oct 11 '13 at 00:54
  • 2
    Also, `returned` needs to be called before calling `.then()` – DanS Nov 07 '13 at 08:10
  • Wouldn't it be better to move `var deferred` inside of the returned function so that the resolved function doesn't inevitably fire multiples times after the wait? – m59 Aug 17 '14 at 18:00
  • This is my preference: http://jsbin.com/gotuyuni/19/edit It makes sense to me, but maybe my solution has problems I haven't noticed. – m59 Aug 17 '14 at 20:13
  • 1
    Has anyone written any tests (preferably in jasmine) for this service? If so, keen to share? :) – Andrew Mar 25 '15 at 14:01
  • 1
    Here are some jasmine tests I put together in relation to this debounce implementation: https://gist.github.com/sheltonial/eed85da3101935d11188 – Andrew Mar 30 '15 at 04:28
  • Adding one more usage example: Basically you need 2 function: 1. `updateEntity = function() { debounceUpdateEntity(); }` 2. `var debounceUpdateEntity = debounce(function() { // your code here}, 200, false);` – chenop Nov 12 '15 at 20:23
  • I would like to emphasis something, the following will not work: `updateEntity = function() { debounce(function() { // your code here}, 200, false)(); } ` This is since debounce create an instance of a function and you want to run the same instance of the function each time. – chenop Nov 14 '15 at 15:28
  • Thanks for this, helped me add scroll/resize debouncing to a webapp im working on! – MeanMatt Dec 02 '16 at 23:33
57

Angular 1.3 has debounce as standard

Worth mentioning that debounce comes built in with Angular 1.3. As you'd expect, it's implemented as a directive. You can do this:

<input ng-model='address' ng-model-options="{ debounce: 500 }" />

The $scope.address attribute is not updated until 500ms after the last keystroke.

If you need more control

If you want more granularity, you can set different bounce times for different events:

<input ng-model='person.address' ng-model-options="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }" />

Here for example we have a 500ms debounce for a keystroke, and no debounce for a blur.

Documentation

Read the documentation here: https://docs.angularjs.org/api/ng/directive/ngModelOptions

superluminary
  • 47,086
  • 25
  • 151
  • 148
33

Since I've written the comments above I've had a bit of a change of heart on this.

The short answer is, you shouldn't need to debounce functions that return values.

Why? Well, philosophically I think it makes more sense to keep debouncing for events and only for events. If you have a method that returns a value that you'd like to debounce, you should instead debounce the event that causes your method to run downstream.

Roy Truelove
  • 22,016
  • 18
  • 111
  • 153
8

Pete BD gave a good start to the debounce service, however, I see two problems:

  1. returns when you should send in a work() callback that uses javascript closure if you need to change state in the caller.
  2. timeout variable - isn't that timeout variable a problem? timeout[] maybe? imagine 2 directives using debounce - signalr, input form validator, I believe the factory approach would break down.

What I am currently using:

I changed factory to a service so each directive gets a NEW instance of debounce aka new instance of the timeout variable. - i haven't ran into a situation where 1 directive will need timeout to be timeout[].

.service('reactService', ['$timeout', '$q', function ($timeout, $q) {
    this.Debounce = function () {
        var timeout;

        this.Invoke = function (func, wait, immediate) {
            var context = this, args = arguments;
            var later = function () {
                timeout = null;
                if (!immediate) {
                    func.apply(context, args);
                }
            };
            var callNow = immediate && !timeout;
            if (timeout) {
                $timeout.cancel(timeout);
            }
            timeout = $timeout(later, wait);
            if (callNow) {
                func.apply(context, args);
            }
        };
        return this;
    }
}]);

in my angularjs remote validator

    .directive('remoteValidator', ['$http', 'reactService', function ($http, reactService) {
        return {
            require: 'ngModel',
            link: function (scope, elm, attrs, ctrl) {
                var newDebounce = new reactService.Debounce();

                var work = function(){
//....
                };

                elm.on('blur keyup change', function () {
                   newDebounce.Invoke(function(){ scope.$apply(work); }, 1000, false);
                });
            }
        };
    }])
Leblanc Meneses
  • 3,001
  • 1
  • 23
  • 26
  • It seems you don't need $q dependency here – Andrey Kuznetsov Feb 27 '14 at 14:19
  • @AndreyKouznetsov agree - cp from Pete BD version, edited the code with change - thanks – Leblanc Meneses Feb 27 '14 at 15:45
  • Part of your answer seems to be a misunderstanding of factories. The factory returns the service, so in Pete's answer, the timeout variable is inside of the service, just like yours. – m59 Aug 17 '14 at 18:19
  • @m59 Documentation: "The Service recipe produces a service just like the Value or Factory recipes, but it does so by invoking a constructor with the new operator." Pete BD version you get a cached value as stated by docs. – Leblanc Meneses Aug 18 '14 at 17:40
  • @LeblancMeneses You're correct about the issue, but not the solution. Both methods produce singletons, so the same instance is used each time. The docs are referring to the way the service is created **the first time**. That same instance will be used from then on. – m59 Aug 18 '14 at 18:13
  • Here's an article on creating services that are not singletons: http://www.tikalk.com/overcoming-angularjs-singleton-service-limitation – m59 Aug 18 '14 at 18:34
  • @m59 can you post your version of the solution as an answer? – Leblanc Meneses Aug 18 '14 at 19:17
  • Updated my solution that requires the caller to instantiate with new. var newDebounce = new reactService.Debounce(); newDebounce.Invoke(...) . This should address @m59 concerns. – Leblanc Meneses Feb 15 '15 at 23:07
1

https://github.com/capaj/ng-tools/blob/master/src/debounce.js

usage:

app.directive('autosavable', function(debounce) {
    return {
        restrict : 'A',
        require : '?ngModel',
        link : function(scope, element, attrs, ngModel) {
            var debounced = debounce(function() {
                scope.$broadcast('autoSave');
            }, 5000, false);

            element.bind('keypress', function(e) {
                debounced();
            });
        }
    };
});
Srinivas
  • 432
  • 5
  • 22
1

Support for this has landed in angularjs#1.3.0.beta6 if you're dealing with a model interaction.

https://docs.angularjs.org/api/ng/directive/ngModelOptions

Alex M
  • 3,506
  • 2
  • 20
  • 23