Achieving Reasonable and Scalable Routing in AngularJS with Flux
I’ve found URL routing to be one of the most frustrating parts of SPA web development with AngularJS. I started with the ngRoute service but quickly transitioned to using ui-router because of it’s powerful state management features, UrlMatcher, and Named Views. Unfortunately, ui-router disappoints in complex use-cases because all of it’s features are tightly coupled and at the same time it does not provide enough flexibility to squeeze out of difficult situations. To demonstrate this point, let’s take a look at a use-case where ui-router falls short…
Note: This article is part of a series about Flux and Angular:
PART 1. How can React and Flux help us create better Angular applications?
PART 2. Achieving Reasonable and Scalable Routing in Angular with Flux
Consider a website with two pages: a search page and a search results page. There is also a node.js server that exposes an API which allows us to POST to /searches/new in order to instantiate a new object on the server which represents the new search. The server requires this object instantiation approach so that it can save each search to a database and later serve it up by id.
This fictitious web site is hosted at searchrat.io and users navigate the following routes:
- searchrat.io/ — search page
- searchrat.io/search/:id — results page
We’ll handle talking with the server via an angular factory named api.
app.factory('api', function() {
return {
searchAlreadyLoaded: false,
search: function(query) {
// send the query to the server
// return a promise
},
getSearch: function(id) {
// ask server for an existing search
// return a promise
}
}
});
When a user clicks search from the search page, we don’t actually know the search id yet. To kick things off, from our search page controller we issue the POST via our api service and then issue a state change:
api.search(scope.query).then(function () {
api.searchAlreadyLoaded = true;
$state.go('search', { id: api.id });
api.searchAlreadyLoaded = false;
});
After the results are loaded, we’ve transitioned to the search state:
$stateProvider.$state('search', {
url: '/search/:id',
template: '<search />',
resolve: {
function(api, $stateParams) {
if (!api.searchAlreadyLoaded) {
return api.getSearch($stateParams.id);
}
}
}
});
Already, you might notice that this code is getting somewhat difficult to read. Inside of our resolve function, in order to determine whether or not the search is a result of a new search (in which case the results have already been loaded), or a page reload (in which case the results still need to be loaded), we check the value of the api.searchAlreadyLoaded property, which is being set in our controller. This is not easily maintainable code.
(Note that an alternative to putting the api.search(…) stuff in our controller is to create an intermediate state with ui-router that has no URL. However, this solution is at least as—if not more—complex.)
Now, what if we want to add an animated loading indicator? We can create an angular factory called loadingIndicator that will show/hide the indicator. This way we can trigger it from the controller:
loadingIndicator.show();
api.search(scope.query).then(function () {
loadingIndicator.hide();
api.searchAlreadyLoaded = true;
$state.go('search', { id: api.id });
api.searchAlreadyLoaded = false;
});
and the resolve block:
$stateProvider.$state('search', {
url: '/search/:id',
template: '<search />',
resolve: {
function(api, $stateParams) {
if (!api.searchAlreadyLoaded) {
var promise = api.getSearch($stateParams.id);
loadingIndicator.show();
promise.then(loadingIndicator.hide);
}
}
}
});
Overall, I think this solution is pretty convoluted. Can we do better?
Now the Flux-based solution. For this approach, a lot of the logic has been consolidated in our Action Creator. The following method will be called both when the user makes a new search from the search page, and when she loads an existing search by navigating directly to the page. The only difference is the data passed via the payload:
function search(payload) {
if (payload.query) { // new search
dispatcher.dispatch('loadingIndicator:show');
api.search(payload.query).then(function (data) {
dispatcher.dispatch('search:loaded', data);
dispatcher.dispatch('route', {state:'search', id:data.id});
dispatcher.dispatch('loadingIndicator:hide');
}); } else if (payload.id) { // existing search
dispatcher.dispatch('loadingIndicator:show');
api.getSearch(payload.id).then(function (data) {
dispatcher.dispatch('search:loaded', data);
dispatcher.dispatch('route', {state:'search', id:data.id});
dispatcher.dispatch('loadingIndicator:hide');
});
}
}
We haven’t actually explored what happens when these various actions are dispatched, but I hope that you’ll agree that this code is already a lot cleaner than the ui-router-based approach. This code is also very predictable: whenever any of these actions are dispatched, 1 or more Stores which have subscribed to be notified will mutate their own state. They are guaranteed to do so synchronously. No other actor other than a Store itself can mutate data within the Store. Furthermore, only an action can trigger a Store to mutate it’s data. The Stores are the actors within a Flux application that keep track of the state of the application. Our view is data-bound (in one direction) to these Stores and will be updated to visually reflect the new state of the application.
An explanation of the various actions from the code above:
- loadingIndicator:show — the appStore will mutate it’s loading property to true, and this property is data-bound to the view which causes the loading animation to display.
- search:loaded — the searchStore will mutate it’s state to save the search results, and the view is data-bound to these results which causes the search results to be rendered.
- route — the routeStore will mutate it’s state to save the latest state and it’s URL, and the browser’s address bar (which we also consider to be a view element) is data-bound to the url property of the routeStore.
At this point, if you’re feeling a little bit confused, don’t worry. I really haven’t given you enough information to understand how a Flux-based application is structured. But hopefully, you have a feel for the type of complexity problem that Flux can solve with ease. In the next section, we’ll look a real example application with source code on GitHub, and explore the tools that we can use to build a scalable routing solution.
Adding Routing to the Color Picker
The full source code is in the angular-flux-routing-example repo.
This section builds off of the techniques I presented in How can React and Flux help us create better Angular applications? We explored how we could use Flux to better architect an Angular project, using a simple color experiment application as an example (Angular version, Angular+Flux version). However, in that article I did leave two important questions unanswered:
- How to deal with ng-model? Woldfram Sokelek found a slick solution using the getterSetter property of ngModelOptions. (this solution only works in Angular 1.3+)
- How to handle URL routing? This is the subject of the following section, and I think that the solution we’ll explore blows any existing angular routing technique out of the water.
In order to explore the routing section, we’ll add an About page to the color experiment:
Thinking about Routing the Flux Way
In order to handle routing in Flux, we break down the various components thusly:
- routeStore: This store keeps track of the current state (like the concept of state in ui-router), and the current path (URL), and provides a method that makes it easy to convert a path to a state name.
- Action Creator: Manages the flow of all sync and async operations.
- URL Bar: We think of the URL bar as a view component, similar to how partials and directives are considered part of the view. In the code below, we use Angular’s built-in $location service to update the browser’s URL.
$rootScope.$watch(function() {
return routeStore.path;
}, function (newPath, oldPath) {
$location.path(newPath);
});
- User-initiated URL changes: When the user clicks the browser’s forward or back buttons, we think of this URL change as an input stream, similar to the stream of input that results from the user clicking any button in the webpage. We can create an event handler for this input stream by $watching $location.path().
$rootScope.$watch(function() {
return $location.path();
}, function(newPath, oldPath) {
if (!routeStore.pathChangedInternally)
actionCreator.routingPathChange({
newPath:newPath,
oldPath:oldPath
});
});
- Internally-generated Route Changes: When the user interacts with the web page in a way that should result in a URL change, we consider this to be an internally-generate route change. Note that internally generated routing changes that update the URL often need to be handled differently than URL updates resulting from direct user action such as clicking the browser’s back or forward buttons.
The Action Creator is the new resolve
In the old days, we often used ui-router’s resolve feature to delay state change until some asynchronous operation completed. The problem with this approach is that its complexity impairs application reasoning, and doesn’t scale well. In Flux, the Action Creator is the main hub through which all sync and async operations flow. In order to facilitate routing, in the new color experiment with routing demo, we setup specific actions for handling routing. The Action Creator dispatches these actions.
- Our actionCreator.goto(..) method is only used for internal route changes, like when a user-initiated button click calls for a URL change:
goto: function(payload) {
this.route(payload, true);
},
- Our actionCreator.routingPathChange(..) method, as we saw above, is only used for URL stream changes. The URL stream is how we think of direct-URL changes by the user. Most commonly, these changes are the result of the user clicking the Back or Forward buttons of the browser:
routingPathChange: function(payload) {
this.route(payload, false)
}
Both of the Action Creator methods above call the route function, the only difference is that they set different values for the pathChangedInternally property which is used by the routeStore to keep track of the type of routing change. The route function works similarly to a switch statement:
route: function(payload, isInternal) {
payload.pathChangedInternally = isInternal;
payload.route = routeStore.getRouteFromPath(payload.path);
if (payload.route) {
var routeName = payload.route.name;
if (this[routeName]
&& this[routeName](payload.route) !== false) {
dispatcher.dispatch('route', payload);
}
}
},// navigate to home
home: function(route) {
if (! colorStore.colorsLoaded) {
dispatcher.dispatch('loadingIndicator:show');
colorApi.fetch().success(function(data) {
dispatcher.dispatch('loaded:colors', {colors: data});
dispatcher.dispatch('route', {route:route});
dispatcher.dispatch('loadingIndicator:hide');
});
return false;
}
},
// navigate to about
about: function(route) {
dispatcher.dispatch('loadingIndicator:show');
aboutApi.fetch().success(function(data) {
dispatcher.dispatch('loaded:about', {people: data});
dispatcher.dispatch('route', {route:route});
dispatcher.dispatch('loadingIndicator:hide');
});
return false;
},
Notice that the route method above will call a method defined on the actionCreator (in this case either home or about) and if that method does not explicitly return false, then the route method will also dispatch the ‘route’ action. It’s important that the ‘route’ action is always dispatched as a result of a successful state change because the ‘route’ action results in an update of the state of the routeStore. Since home and about are async in this example, they handle dispatching ‘route’ themselves.
As a result of this architecture, our action creator now has two distinct types of methods:
- actionCreator.fn(payload)
Called directly from the view layer in response to user interaction. - actionCreator.fn(route)
Called from actionCreator#route method.
Finally, it’s important to point out that while calling actionCreator.goto(payload) directly from the view layer will often be sufficient for simple use cases, for more complex scenarios we might want to add another method to the actionCreator which in turn calls actionCreator.goto(payload). One of the nice things about this approach is that we can arrange this new method definition above or below it’s corresponding actionCreator.fn(route) method definition. This logical arrangement makes it very easy to reason about the code. Compare this to the ui-router approach which often results in the code for a specific domain spread throughout the application in controllers and various resolve blocks.
Utilizing Yahoo’s routr to Handle State and URL Matching
The final piece to this puzzle, is to implement the routeStore. In Flux, Stores are tasked with managing application state and thus the routeStore is tasked with managing routing state. This means that the routeStore understands the URL structure of the application and assigns named states to the various URLs.
Note that when we say that a Store manages state we are really just talking about data. How that data effects the visual appearance of the application is not the direct responsibility of the Store. For example, the appStore object might have a theme property with a corresponding string value. This data stored in the appStore relates directly to some part of the UI, but the appStore isn’t responsible for the rendering of the UI.
In order to achieve a Flux-friendly solution to routing in the new color experiment, we use Yahoo’s routr project to handle state and URL matching. Although routr is a project originally intended to be used with React, it is actually framework-agnostic which means it can be used with Angular without a hitch. In fact, the solution we look at here is almost exactly the type of solution we would also use with a Flux+react project. Furthermore, routr is so flexible that it can also be used on the server with nodejs.
Here is what our routeStore with routr looks like:
The ‘route’ action handler is updating the path and pathChangedInternally properties which will result in a URL change when pathChangedInternally is false, as we saw in the User-initiated URL changes bullet point earlier.
Reflecting state change in the view
Whether or not routeStore.pathChangedInternally is true, the routeStore’s ‘route’ action handler is also updated the routeStore.currentRouteName property which will result in a change to the view via the ui-view directive. Note that ui-view is really the only part of ui-router that we are using and it could easily be swapped out for a different solution like ng-view. However, ui-view is more powerful and if features like nested views are needed, it’s still a useful tool despite the fact that it can’t be used for the rest of the routing implementation in a Flux application. This is how we bind the routeStore.currentRouteName property to our ui-view directive:
$rootScope.$watch(function() {
return routeStore.currentRouteName;
}, function (newState, oldState) {
if (newState) $state.go(newState);
});
$state.go will trigger a view change based on the state→view relationships we setup in a config block:
// Note that we should probably generate these states
// automatically from data in routeStore.routes, but
// it's done this way for simplicity's sake.$stateProvider
.state('home', { template: '<home />' })
.state('about', { template: '<about />' })
Conclusion
While the Flux-based solution requires a little bit more setup, I think it’s well worth it. The ability to be able to look at the Action Creator and get a really good idea about what’s going on in our application is a huge win. Furthermore, this solution is a lot more powerful than ngRoute and ui-router. If you have ever found yourself in a situation where the existing routing solutions simply wouldn’t allow you to do what you needed to, or they led to an overly-complex solution I urge you to give this approach a shot.