Delaying AngularJS route change until model loaded to prevent flicker
Asked Answered
V

13

321

I am wondering if there is a way (similar to Gmail) for AngularJS to delay showing a new route until after each model and its data has been fetched using its respective services.

For example, if there were a ProjectsController that listed all Projects and project_index.html which was the template that showed these Projects, Project.query() would be fetched completely before showing the new page.

Until then, the old page would still continue to show (for example, if I were browsing another page and then decided to see this Project index).

Virgil answered 15/8, 2012 at 15:12 Comment(0)
V
374

$routeProvider resolve property allows delaying of route change until data is loaded.

First define a route with resolve attribute like this.

angular.module('phonecat', ['phonecatFilters', 'phonecatServices', 'phonecatDirectives']).
  config(['$routeProvider', function($routeProvider) {
    $routeProvider.
      when('/phones', {
        templateUrl: 'partials/phone-list.html', 
        controller: PhoneListCtrl, 
        resolve: PhoneListCtrl.resolve}).
      when('/phones/:phoneId', {
        templateUrl: 'partials/phone-detail.html', 
        controller: PhoneDetailCtrl, 
        resolve: PhoneDetailCtrl.resolve}).
      otherwise({redirectTo: '/phones'});
}]);

notice that the resolve property is defined on route.

function PhoneListCtrl($scope, phones) {
  $scope.phones = phones;
  $scope.orderProp = 'age';
}

PhoneListCtrl.resolve = {
  phones: function(Phone, $q) {
    // see: https://groups.google.com/forum/?fromgroups=#!topic/angular/DGf7yyD4Oc4
    var deferred = $q.defer();
    Phone.query(function(successData) {
            deferred.resolve(successData); 
    }, function(errorData) {
            deferred.reject(); // you could optionally pass error data here
    });
    return deferred.promise;
  },
  delay: function($q, $defer) {
    var delay = $q.defer();
    $defer(delay.resolve, 1000);
    return delay.promise;
  }
}

Notice that the controller definition contains a resolve object which declares things which should be available to the controller constructor. Here the phones is injected into the controller and it is defined in the resolve property.

The resolve.phones function is responsible for returning a promise. All of the promises are collected and the route change is delayed until after all of the promises are resolved.

Working demo: http://mhevery.github.com/angular-phonecat/app/#/phones Source: https://github.com/mhevery/angular-phonecat/commit/ba33d3ec2d01b70eb5d3d531619bf90153496831

Virgil answered 15/8, 2012 at 15:12 Comment(23)
In example you use $defer. Is $defer was replaced by $timeout? As far as I understand from example it delays route change not until data is loaded, but on 1 sec. And we can only hope that data will be loaded for this second...Apennines
the defer/delay is only used as an example. It delays at least on second or until data lodade.Virgil
the way to think about is that all of the promises must be resolved before the page continues.Virgil
Misko, how can I go about creating a promise that waits until a variable in $scope changes to what I care about? I tried, but it doesn't seem that the promises have access to the $scope variable?Cornhusk
@MiskoHevery Thanks, but I can't fully understand why resolve.phones returns a promise. As far as I know from documentation Phone.query() returns empty array which will be populated with the actual data when data is returned from the server.Apennines
@MiskoHevery - what if your controllers are inside a module and are defined as a string rather than function. How could you setup the resolve attribute like you do?Ardra
How is this used in angular.controller() type controller definitions? In the $routeProvider stuff, I thought you had to use string names of controllers.Wolters
@MiskoHevery How do I pass in the value if the controller is using an `$inject' to inject services into the controller?Liebig
Tried to use this - and the promises from $resource are NOT resolved when the controller starts; promises done using $q are resolved though.Bellied
Read here regarding $resource and promises - and why the above doesn't work as expected: groups.google.com/forum/?fromgroups=#!topic/angular/DGf7yyD4Oc4Rundlet
Any example using angular.controller() and with the latest version of AngularJS?Hydrochloride
For those interested in the minification friendly angular.controller() style, I just wrote a new answer below. I hope it's helpful. Cheers.Rodent
@blesh, when you use angular.controller(), you can assign result of this function to a variable (var MyCtrl = angular.controller(...)) and then work with that further (MyCtrl.loadData = function(){..}). Check out egghead's video, the code is shown there straight away: egghead.io/video/0uvAseNXDr0Exhibition
@beret good point, I guess because of hoisting and the fact the controller would be a reference type, that is all you'd have to do. It's so obvious, I feel silly having asked the above question. +1 to you for pointing out the obvious. haha.Wolters
$defer has been renamed to $timeout see: groups.google.com/d/msg/angular/IxYes7wyYQA/M3PpOMyKxZYJWaligore
I'm having problems switching back and forth to a page and still having resolve called. Is that possible?Saloon
I'd still like a nice way to do with without having place your controller in a global. I don't want to litter with globals all over the place. You can do it with a constant, but it would be nice to be able to put the resolve function on/in the controller, not somewhere else.Inside
@ErikHonn Wrap your Angular source files in a self executing function so you don't have to worry about mucking up your global scope.Mayest
I don't run other js than Angular, so that would still make it global for my entire project :PInside
The delay is not required right? And can this be updated with the new $timeout method?Womb
Assigning it to app.controller result is not right, as the result returns the same object. a = angular.module('a', []); a.controller(function () {}) == a.controller(function (){}) so it won't work with multiple controllers.Andonis
How can I cancel route navigation if I got rejected promise?Meara
@GangadharJannu Listen for $routeChangeErrorOchrea
V
51

Here's a minimal working example which works for Angular 1.0.2

Template:

<script type="text/ng-template" id="/editor-tpl.html">
    Editor Template {{datasets}}
</script>

<div ng-view>

</div>

JavaScript:

function MyCtrl($scope, datasets) {    
    $scope.datasets = datasets;
}

MyCtrl.resolve = {
    datasets : function($q, $http) {
        var deferred = $q.defer();

        $http({method: 'GET', url: '/someUrl'})
            .success(function(data) {
                deferred.resolve(data)
            })
            .error(function(data){
                //actually you'd want deffered.reject(data) here
                //but to show what would happen on success..
                deferred.resolve("error value");
            });

        return deferred.promise;
    }
};

var myApp = angular.module('myApp', [], function($routeProvider) {
    $routeProvider.when('/', {
        templateUrl: '/editor-tpl.html',
        controller: MyCtrl,
        resolve: MyCtrl.resolve
    });
});​
​

http://jsfiddle.net/dTJ9N/3/

Streamlined version:

Since $http() already returns a promise (aka deferred), we actually don't need to create our own. So we can simplify MyCtrl. resolve to:

MyCtrl.resolve = {
    datasets : function($http) {
        return $http({
            method: 'GET', 
            url: 'http://fiddle.jshell.net/'
        });
    }
};

The result of $http() contains data, status, headers and config objects, so we need to change the body of MyCtrl to:

$scope.datasets = datasets.data;

http://jsfiddle.net/dTJ9N/5/

Vaucluse answered 15/8, 2012 at 15:13 Comment(5)
I'm trying to do something like this but having trouble with injecting 'datasets' as it is not defined. Any thoughts?Dipetalous
Hey mb21, I think you might be able to help me out with this question: #14272213Couvade
Could someone help me convert this answer to the app.controller('MyCtrl') format? jsfiddle.net/5usya/1 didn't work for me.Orphanage
i get an error: Unknown provider: datasetsProvider <- datasetsLymn
You can make your answer simpler by replacing datasets with this: function($http) { return $http({method: 'GET', url: '/someUrl'}) .then( function(data){ return data;}, function(reason){return 'error value';} ); }Southwick
R
31

I see some people asking how to do this using the angular.controller method with minification friendly dependency injection. Since I just got this working I felt obliged to come back and help. Here's my solution (adopted from the original question and Misko's answer):

angular.module('phonecat', ['phonecatFilters', 'phonecatServices', 'phonecatDirectives']).
  config(['$routeProvider', function($routeProvider) {
    $routeProvider.
      when('/phones', {
        templateUrl: 'partials/phone-list.html', 
        controller: PhoneListCtrl, 
        resolve: { 
            phones: ["Phone", "$q", function(Phone, $q) {
                var deferred = $q.defer();
                Phone.query(function(successData) {
                  deferred.resolve(successData); 
                }, function(errorData) {
                  deferred.reject(); // you could optionally pass error data here
                });
                return deferred.promise;
             ]
            },
            delay: ["$q","$defer", function($q, $defer) {
               var delay = $q.defer();
               $defer(delay.resolve, 1000);
               return delay.promise;
              }
            ]
        },

        }).
      when('/phones/:phoneId', {
        templateUrl: 'partials/phone-detail.html', 
        controller: PhoneDetailCtrl, 
        resolve: PhoneDetailCtrl.resolve}).
      otherwise({redirectTo: '/phones'});
}]);

angular.controller("PhoneListCtrl", [ "$scope", "phones", ($scope, phones) {
  $scope.phones = phones;
  $scope.orderProp = 'age';
}]);

Since this code is derived from the question/most popular answer it is untested, but it should send you in the right direction if you already understand how to make minification friendly angular code. The one part that my own code didn't requires was an injection of "Phone" into the resolve function for 'phones', nor did I use any 'delay' object at all.

I also recommend this youtube video http://www.youtube.com/watch?v=P6KITGRQujQ&list=UUKW92i7iQFuNILqQOUOCrFw&index=4&feature=plcp , which helped me quite a bit

Should it interest you I've decided to also paste my own code (Written in coffeescript) so you can see how I got it working.

FYI, in advance I use a generic controller that helps me do CRUD on several models:

appModule.config ['$routeProvider', ($routeProvider) ->
  genericControllers = ["boards","teachers","classrooms","students"]
  for controllerName in genericControllers
    $routeProvider
      .when "/#{controllerName}/",
        action: 'confirmLogin'
        controller: 'GenericController'
        controllerName: controllerName
        templateUrl: "/static/templates/#{controllerName}.html"
        resolve:
          items : ["$q", "$route", "$http", ($q, $route, $http) ->
             deferred = $q.defer()
             controllerName = $route.current.controllerName
             $http(
               method: "GET"
               url: "/api/#{controllerName}/"
             )
             .success (response) ->
               deferred.resolve(response.payload)
             .error (response) ->
               deferred.reject(response.message)

             return deferred.promise
          ]

  $routeProvider
    .otherwise
      redirectTo: '/'
      action: 'checkStatus'
]

appModule.controller "GenericController", ["$scope", "$route", "$http", "$cookies", "items", ($scope, $route, $http, $cookies, items) ->

  $scope.items = items
      #etc ....
    ]
Rodent answered 14/3, 2013 at 19:11 Comment(4)
Do I infer correctly from your example and my failed attempts that it's now impossible to reference a resolve function in the controller, in recent versions of Angular? So it has to be declared right in the config as it is here?Stillness
@XMLilley I'm pretty sure that's the case. This example was from 1.1.2 when I wrote it, I believe. I did not see any documentation on putting resolve inside of a controllerRodent
Cool, thanks. There's lots of examples of doing so on SO (like the top two here), but they're all from 2012 and early 2013. It's an elegant approach, but appears to be deprecated. The cleanest alternative now seems to be writing individual services that are promise objects.Stillness
Thanks this worked for me. For anyone else who gets errors about undefined $defer service, do note that in version 1.5.7 of AngularJS, you want to use $timeout instead.Superfetation
C
18

This commit, which is part of version 1.1.5 and above, exposes the $promise object of $resource. Versions of ngResource including this commit allow resolving resources like this:

$routeProvider

resolve: {
    data: function(Resource) {
        return Resource.get().$promise;
    }
}

controller

app.controller('ResourceCtrl', ['$scope', 'data', function($scope, data) {

    $scope.data = data;

}]);
Cream answered 26/5, 2013 at 12:6 Comment(5)
Which versions include that commit, please?Stillness
The latest unstable version (1.1.5) includes this commit. ajax.googleapis.com/ajax/libs/angularjs/1.1.5/angular.min.jsCream
I like this less verbose approach. Would be nice to create a promise from the actual data object and pass that in directly, but this is so little code that it works nicely.Ofay
How would the resource access $routeParams? For example: in GET '/api/1/apps/:appId' --> App.get({id: $routeParams.appId}).$promise(); I can't use like thisSimoom
@Simoom you inject $route to your resolve and use $route.current.params. Be careful, $routeParams is still pointing to the old route.Sheepskin
S
16

This snippet is dependency injection friendly (I even use it in combination of ngmin and uglify) and it's a more elegant domain driven based solution.

The example below registers a Phone resource and a constant phoneRoutes, which contains all your routing information for that (phone) domain. Something I didn't like in the provided answer was the location of the resolve logic -- the main module should not know anything or be bothered about the way the resource arguments are provided to the controller. This way the logic stays in the same domain.

Note: if you're using ngmin (and if you're not: you should) you only have to write the resolve functions with the DI array convention.

angular.module('myApp').factory('Phone',function ($resource) {
  return $resource('/api/phone/:id', {id: '@id'});
}).constant('phoneRoutes', {
    '/phone': {
      templateUrl: 'app/phone/index.tmpl.html',
      controller: 'PhoneIndexController'
    },
    '/phone/create': {
      templateUrl: 'app/phone/edit.tmpl.html',
      controller: 'PhoneEditController',
      resolve: {
        phone: ['$route', 'Phone', function ($route, Phone) {
          return new Phone();
        }]
      }
    },
    '/phone/edit/:id': {
      templateUrl: 'app/phone/edit.tmpl.html',
      controller: 'PhoneEditController',
      resolve: {
        form: ['$route', 'Phone', function ($route, Phone) {
          return Phone.get({ id: $route.current.params.id }).$promise;
        }]
      }
    }
  });

The next piece is injecting the routing data when the module is in the configure state and applying it to the $routeProvider.

angular.module('myApp').config(function ($routeProvider, 
                                         phoneRoutes, 
                                         /* ... otherRoutes ... */) {

  $routeProvider.when('/', { templateUrl: 'app/main/index.tmpl.html' });

  // Loop through all paths provided by the injected route data.

  angular.forEach(phoneRoutes, function(routeData, path) {
    $routeProvider.when(path, routeData);
  });

  $routeProvider.otherwise({ redirectTo: '/' });

});

Testing the route configuration with this setup is also pretty easy:

describe('phoneRoutes', function() {

  it('should match route configuration', function() {

    module('myApp');

    // Mock the Phone resource
    function PhoneMock() {}
    PhoneMock.get = function() { return {}; };

    module(function($provide) {
      $provide.value('Phone', FormMock);
    });

    inject(function($route, $location, $rootScope, phoneRoutes) {
      angular.forEach(phoneRoutes, function (routeData, path) {

        $location.path(path);
        $rootScope.$digest();

        expect($route.current.templateUrl).toBe(routeData.templateUrl);
        expect($route.current.controller).toBe(routeData.controller);
      });
    });
  });
});

You can see it in full glory in my latest (upcoming) experiment. Although this method works fine for me, I really wonder why the $injector isn't delaying construction of anything when it detects injection of anything that is a promise object; it would make things soooOOOOOooOOOOO much easier.

Edit: used Angular v1.2(rc2)

Schmaltz answered 6/10, 2013 at 21:1 Comment(2)
This excellent answer seems much more in line with the "Angular" philosophy (encapsulation, etc). We should all be making a conscious effort to stop logic from creeping all over the codebase like kudzu.Trenchant
I really wonder why the $injector isn't delaying construction of anything when it detects injection of anything that is a promise object I'm guessing they omitted this functionality because it might encourages design patterns that negatively effect the responsiveness of apps. The ideal app in their mind is one that's truly asynchronous, so resolving should be an edge case.Trenchant
B
11

Delaying showing the route is sure to lead to an asynchronous tangle... why not simply track the loading status of your main entity and use that in the view. For example in your controller you might use both the success and error callbacks on ngResource:

$scope.httpStatus = 0; // in progress
$scope.projects = $resource.query('/projects', function() {
    $scope.httpStatus = 200;
  }, function(response) {
    $scope.httpStatus = response.status;
  });

Then in the view you could do whatever:

<div ng-show="httpStatus == 0">
    Loading
</div>
<div ng-show="httpStatus == 200">
    Real stuff
    <div ng-repeat="project in projects">
         ...
    </div>
</div>
<div ng-show="httpStatus >= 400">
    Error, not found, etc. Could distinguish 4xx not found from 
    5xx server error even.
</div>
Bradfordbradlee answered 3/9, 2012 at 17:59 Comment(4)
Perhaps exposing HTTP status to the view isn't right, anymore than dealing with CSS classes and DOM elements belong in the controller. I'd probably use the same idea but abstract status away in isValid() and isLoaded().Bradfordbradlee
This is indeed not the best separation of concerns, plus that it will crash if you have nested controllers that depend on the specific object.Schmaltz
This is pretty clever.. as to exposing the status codes to the view, you could just stick the http logic in scope properties within the controller, then bind to them. Also if you making multiple ajax calls that happen in the background you will want to do this anyway.Protuberancy
This would be fine if the issue was a matter of simply delaying a view. But resolve is best used if you need to delay the instantiation of a controller-- not just the view. (Ex: If you need to be sure your JSON is loaded because your controller passes it to a directive before it's wired.) From the docs: "the router will wait for them all to be resolved or one to be rejected before the controller is instantiated".Petrel
B
8

I worked from Misko's code above and this is what I've done with it. This is a more current solution since $defer has been changed to $timeout. Substituting $timeout however will wait for the timeout period (in Misko's code, 1 second), then return the data hoping it's resolved in time. With this way, it returns asap.

function PhoneListCtrl($scope, phones) {
  $scope.phones = phones;
  $scope.orderProp = 'age';
}

PhoneListCtrl.resolve = {

  phones: function($q, Phone) {
    var deferred = $q.defer();

    Phone.query(function(phones) {
        deferred.resolve(phones);
    });

    return deferred.promise;
  }
}
Beers answered 7/9, 2012 at 5:4 Comment(0)
R
7

Using AngularJS 1.1.5

Updating the 'phones' function in Justen's answer using AngularJS 1.1.5 syntax.

Original:

phones: function($q, Phone) {
    var deferred = $q.defer();

    Phone.query(function(phones) {
        deferred.resolve(phones);
    });

    return deferred.promise;
}

Updated:

phones: function(Phone) {
    return Phone.query().$promise;
}

Much shorter thanks to the Angular team and contributors. :)

This is also the answer of Maximilian Hoffmann. Apparently that commit made it into 1.1.5.

Ricebird answered 28/7, 2013 at 7:3 Comment(2)
I can't seem to find anything about $promise in the docs. It might've been taken out as of v2.0+.Trenchant
It is only available in 1.2Chisholm
O
5

You can use $routeProvider resolve property to delay route change until data is loaded.

angular.module('app', ['ngRoute']).
  config(['$routeProvider', function($routeProvider, EntitiesCtrlResolve, EntityCtrlResolve) {
    $routeProvider.
      when('/entities', {
        templateUrl: 'entities.html', 
        controller: 'EntitiesCtrl', 
        resolve: EntitiesCtrlResolve
      }).
      when('/entity/:entityId', {
        templateUrl: 'entity.html', 
        controller: 'EntityCtrl', 
        resolve: EntityCtrlResolve
      }).
      otherwise({redirectTo: '/entities'});
}]);

Notice that the resolve property is defined on route.

EntitiesCtrlResolve and EntityCtrlResolve is constant objects defined in same file as EntitiesCtrl and EntityCtrl controllers.

// EntitiesCtrl.js

angular.module('app').constant('EntitiesCtrlResolve', {
  Entities: function(EntitiesService) {
    return EntitiesService.getAll();
  }
});

angular.module('app').controller('EntitiesCtrl', function(Entities) {
  $scope.entities = Entities;

  // some code..
});

// EntityCtrl.js

angular.module('app').constant('EntityCtrlResolve', {
  Entity: function($route, EntitiesService) {
    return EntitiesService.getById($route.current.params.projectId);
  }
});

angular.module('app').controller('EntityCtrl', function(Entity) {
  $scope.entity = Entity;

  // some code..
});
Onieonion answered 14/11, 2014 at 12:4 Comment(0)
S
3

I like darkporter's idea because it will be easy for a dev team new to AngularJS to understand and worked straight away.

I created this adaptation which uses 2 divs, one for loader bar and another for actual content displayed after data is loaded. Error handling would be done elsewhere.

Add a 'ready' flag to $scope:

$http({method: 'GET', url: '...'}).
    success(function(data, status, headers, config) {
        $scope.dataForView = data;      
        $scope.ready = true;  // <-- set true after loaded
    })
});

In html view:

<div ng-show="!ready">

    <!-- Show loading graphic, e.g. Twitter Boostrap progress bar -->
    <div class="progress progress-striped active">
        <div class="bar" style="width: 100%;"></div>
    </div>

</div>

<div ng-show="ready">

    <!-- Real content goes here and will appear after loading -->

</div>

See also: Boostrap progress bar docs

Stagecoach answered 27/2, 2013 at 18:51 Comment(3)
Falls apart a bit if you're loading multiple pieces of data. How do you know if everything loaded?Maxa
Things have moved on since I added this answer in Feb, with a lot more activity on this page. Looks like there is better support in Angular for solving this problem now than what is suggested here. Cheers,Stagecoach
I arrive a bit late, but dealing with multiple pieces of data is not of big concern. You just have to use separate variables (booleans : isReadyData1, isReadyData2 etc.) for each request, and set the $scope.ready = isReadyData1 && isReadyData2 ...; works well for me.Sunbeam
S
1

I liked above answers and learned a lot from them but there is something that is missing in most of the above answers.

I was stuck in a similar scenario where I was resolving url with some data that is fetched in the first request from the server. Problem I faced was what if the promise is rejected.

I was using a custom provider which used to return a Promise which was resolved by the resolve of $routeProvider at the time of config phase.

What I want to stress here is the concept of when it does something like this.

It sees the url in url bar and then respective when block in called controller and view is referred so far so good.

Lets say I have following config phase code.

App.when('/', {
   templateUrl: '/assets/campaigns/index.html',
   controller: 'CampaignListCtr',
   resolve : {
      Auth : function(){
         return AuthServiceProvider.auth('campaign');
      }
   }
})
// Default route
.otherwise({
   redirectTo: '/segments'
});

On root url in browser first block of run get called otherwise otherwise gets called.

Let's imagine a scenario I hit rootUrl in address bar AuthServicePrivider.auth() function gets called.

Lets say Promise returned is in reject state what then???

Nothing gets rendered at all.

Otherwise block will not get executed as it is for any url which is not defined in the config block and is unknown to angularJs config phase.

We will have to handle the event that gets fired when this promise is not resolved. On failure $routeChangeErorr gets fired on $rootScope.

It can be captured as shown in code below.

$rootScope.$on('$routeChangeError', function(event, current, previous, rejection){
    // Use params in redirection logic.
    // event is the routeChangeEvent
    // current is the current url
    // previous is the previous url
    $location.path($rootScope.rootPath);
});

IMO It's generally a good idea to put event tracking code in run block of application. This code run just after the config phase of the application.

App.run(['$routeParams', '$rootScope', '$location', function($routeParams, $rootScope, $location){
   $rootScope.rootPath = "my custom path";
   // Event to listen to all the routeChangeErrors raised
   // by the resolve in config part of application
   $rootScope.$on('$routeChangeError', function(event, current, previous, rejection){
       // I am redirecting to rootPath I have set above.
       $location.path($rootScope.rootPath);
   });
}]);

This way we can handle promise failure at the time of config phase.

Stifle answered 12/1, 2015 at 10:39 Comment(0)
P
0

I have had a complex multi-level sliding panel interface, with disabled screen layer. Creating directive on disable screen layer that would create click event to execute the state like

$state.go('account.stream.social.view');

were producing a flicking effect. history.back() instead of it worked ok, however its not always back in history in my case. SO what I find out is that if I simply create attribute href on my disable screen instead of state.go , worked like a charm.

<a class="disable-screen" back></a>

Directive 'back'

app.directive('back', [ '$rootScope', function($rootScope) {

    return {
        restrict : 'A',
        link : function(scope, element, attrs) {
            element.attr('href', $rootScope.previousState.replace(/\./gi, '/'));
        }
    };

} ]);

app.js I just save previous state

app.run(function($rootScope, $state) {      

    $rootScope.$on("$stateChangeStart", function(event, toState, toParams, fromState, fromParams) {         

        $rootScope.previousState = fromState.name;
        $rootScope.currentState = toState.name;


    });
});
Preconcert answered 8/1, 2016 at 1:51 Comment(0)
D
-2

One possible solution might be to use the ng-cloak directive with the element where we are using the models e.g.

<div ng-cloak="">
  Value in  myModel is: {{myModel}}
</div>

I think this one takes least effort.

Divinize answered 3/10, 2014 at 18:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.