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 )

Experiment: Recreating The InVision App User Interface With The Angular 5 Router

By Ben Nadel on

Sometimes, it's hard to see where the rough-edges in a framework exist until you actually try to do something non-trivial with said framework. This is true for all frameworks, including Angular. Maybe especially Angular, since this framework provides an end-to-end solution for application development. That said, I wanted to learn more about the Angular 5 Router; so, rather than putting together a few trivial demos, I attempted the very non-trivial act of recreating the entire InVision App user interface. Of course, I didn't even come close to fleshing out all of the views; but, the task was sufficiently difficult and it taught me a heck of a lot about how the Angular Router works. And, to be fair, I only scratched the surface of what the Router can do. For example, I didn't even get into the lazy-loading of modules.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

I wanted this project to be non-trivial. But, this project ended up encompassing way more than I anticipated. Partly because I ran into a lot of Angular hurdles along the way; and, partly because I forgot just how massive the InVision App interface is. All in all, I've been working on this project on-and-off since October 1st, 2017. I've since squashed the branch; but, you can see the many incremental steps I've taken in the last four-and-a-half months:


 
 
 

 
 Git commit log for Angular 5 router deep dive. 
 
 
 

Part of why this took so long, other than the large amount of Views that this project includes, is that I ran into a lot of Angular quirks and non-specific oddities that I had to solve before I could move forward. Each one of these hurdles ended up spawning a tangential investigation that often lead to GitHub issues and blog posts. In fact, the following posts are all a direct result of this Angular Router deep-dive:

Honestly, just these tangential explorations alone were worth the cost of the deep-dive. Some of these problems really challenged me to think holistically about how an application should work. For example, the last one - the Scroll Retention Polyfill - took me like 3 weeks to figure out. And, it's not even all that well done. But, I'm glad I got something working.

One of the hardest things about this deep-dive was just trying to figure how to define the Routes and organize the Views. One thing I was absolutely positive that I wanted to avoid was having to rename Components on import. Meaning, I didn't want my application to be littered with garbage statements like this:

import { DetailView as SomeUniqueWidgetDetailView } from "./some/unique/widgets/detail"

I also wanted avoid performing any "import *" statements. I prefer explicit imports over globbing as I find it makes the code easier to reason about. It also makes it much more clear as to the intent of each import.

Ultimately, this brought me to a solution in which my entire Route configuration tree is a composite of static propreties exposed on each View Module. The bootstrapped application module defines its routes based on the top View Module:

  • RouterModule.forRoot(
  • // I'm building the entire route tree with nested route configurations at
  • // boot-time. Currently, this feels like the lesser of all evils. With this
  • // approach, I am not sure if I will ever have the ability to lazy-load? But,
  • // this feels more straight-forward than anything I've seen so far. Nested
  • // routes seems to be a thing of much discussion, even today.
  • // --
  • // Read More: https://github.com/angular/angular/issues/10958
  • // Read More: https://github.com/angular/angular/issues/10647
  • ShellViewModule.routes,
  • {
  • // Tell the router to use the HashLocationStrategy.
  • useHash: true,
  • enableTracing: false
  • }
  • )

... which, in turn, defines its routes based on the routes of its child modules:

  • @NgModule({
  • imports: [
  • BoardsViewModule,
  • ConsoleViewModule,
  • FreehandsViewModule,
  • InboxViewModule,
  • ModalViewModule,
  • OopsViewModule,
  • ProductUpdatesViewModule,
  • SharedModule,
  • StandardViewModule
  • ],
  • declarations: [
  • ShellViewComponent
  • ]
  • })
  • export class ShellViewModule {
  •  
  • static routes: Routes = [
  • {
  • // NOTE: Normally, I wouldn't include a "path" here because I would defer to
  • // the child routes to define their own relevant prefix. However, since the
  • // ShellView component has several NAMED OUTLETs (Inbox, Modal), we have to
  • // provide a path or the named outlets will break.
  • // --
  • // Read More: https://github.com/angular/angular/issues/14662
  • path: "app",
  • children: [
  • ...BoardsViewModule.routes,
  • ...ConsoleViewModule.routes,
  • ...InboxViewModule.routes,
  • ...ModalViewModule.routes,
  • ...OopsViewModule.routes,
  • ...ProductUpdatesViewModule.routes,
  • ...StandardViewModule.routes,
  • ...FreehandsViewModule.routes,
  •  
  • // Handle the "no route" case.
  • {
  • path: "",
  • pathMatch: "full",
  • redirectTo: "projects"
  • }
  • ]
  • },
  •  
  • // Handle the "no route" case.
  • {
  • path: "",
  • pathMatch: "full",
  • redirectTo: "app/projects"
  • },
  •  
  • // Handle the catch-all for not found routes.
  • {
  • path: "**",
  • redirectTo: "/app/oops/not-found"
  • }
  • ];
  •  
  • }

... which, in turn, defines its routes based on the routes of its child modules:

  • @NgModule({
  • imports: [
  • ActivityViewModule,
  • LearnViewModule,
  • PeopleViewModule,
  • ProjectsViewModule,
  • PrototypesViewModule,
  • SharedModule
  • ],
  • declarations: [
  • StandardViewComponent
  • ]
  • })
  • export class StandardViewModule {
  •  
  • static routes: Routes = [
  • {
  • path: "",
  • component: StandardViewComponent,
  • children: [
  • ...ActivityViewModule.routes,
  • ...LearnViewModule.routes,
  • ...PeopleViewModule.routes,
  • ...ProjectsViewModule.routes,
  • ...PrototypesViewModule.routes,
  •  
  • // Handle the "no route" case.
  • {
  • path: "",
  • pathMatch: "full",
  • redirectTo: "projects"
  • }
  • ]
  • }
  • ];
  •  
  • }

... and so on, down the entire view tree.

This approach worked well for me. But, it is certainly not without its drawbacks. For one, there's no single place to view a comprehensive list of routes that the application accepts. Since each segment of a route is, essentially, defined by a different module, you have to drill down into the modules in order to see how the paths get constructed.

Another drawback is that I think this approach may prevent me from ever lazy-loading modules since the entire route configuration is a composite of static structures that are known at compile time. That said, I am barely confident about putting together a routable application that doesn't use lazy-loading - worrying about lazy-loading at this point is putting the cart before the horse. Let me figure out how to solve step 1 before I worry about solving step 2.

The primary benefit of this approach - the thing that makes the drawbacks acceptable - is that each module is only worried about the View components that it owns (for the most part). No renaming of components. No worrying about naming collisions. Each module just knows about its own routes, and simply defers to its child modules to fill-in the subsequent route segments.

One thing that felt odd, but ended up being mostly pleasing, is that each routable View ended up becoming a Module. This is because each View was responsible for defining its own sub-routes. In the end, I liked this setup because it also allowed me to define directives that were scoped to a particular View. I can't imagine having to define globally-unique directives in a non-trivial application.

By convention, all of my routable Views were in folders that were suffixed with "-view". Here's a screenshot of my folder-tree, where you can see the "-view" convention alongside the View-specific modules:


 
 
 

 
 Folder structure for Angular 5 router deep dive. 
 
 
 

I found that this organizational approach made the View files really easy to find. I just had to think about how they were organized in the user interface; and then following the same hierarchy in the folder structure.

The other part of this exploration that I found both challenging and extremely exciting was the use of Secondary Outlets. One of the most powerful features of the Angular 5 router is the fact that it can render multiple routes inside a single URL. This allows me to create very intuitive back-button behavior and seamless copy-paste functionality for my application state (ie, copying the URL and send it to someone else).

In the following screenshot, all of the major views (primary, Inbox, and Product Updates) are all activated and controlled by the URL:


 
 
 

 
 Primary and secondary outlets in Angular 5 router deep dive. 
 
 
 

It's really had to see in the screenshot, obviously, but the URL for this view state is this:

/app/(projects/list//inbox:inbox/boards/1/conversations/1//updates:product-updates)

The parens contain three separate routes off of the "/app" route: one primary and two secondary. This makes me so happy, it's kind of scary. I am completely enamored with moving as much state as I can into the URL.

Though, secondary outlets are not without their complexities. For example, defining a link that points to a secondary outlet is not exactly pretty. And, secondary outlets all have to be contained within a non-empty parent segment. This means that you cannot have a secondary outlet be a direct child of a pathless parent component. That's why my whole app is prefixed with the "/app" segment. And, it's also why I put all of my secondary outlets in the top-most Shell View of the application:

  • <router-outlet></router-outlet>
  •  
  • <!--
  • This is our SECONDARY route for the updates.
  • --
  • NOTE: Really, I wanted this to be inside the Standard View so that it was only
  • visible when the Standard View was rendered; however, the standard view starts with
  • an empty ("") parent route, which makes secondary views impossible to use. As such,
  • it's being moved up to the Shell view where it will be harder to "hide". Oh well.
  •  
  • I could have just included it as a Component in the Standard View; but, I wanted the
  • view to be navigable using the back-button (as this is the expected behavior).
  • -->
  • <router-outlet name="updates"></router-outlet>
  •  
  • <!-- This is our SECONDARY route for the inbox. -->
  • <router-outlet name="inbox"></router-outlet>
  •  
  • <!-- This is our SECONDARY route for the modal window. -->
  • <router-outlet name="modal"></router-outlet>

In the end, I don't want you to look at this deep dive and see it as a set of "best practices" on how to use the Angular router. This is nothing more than the solution I've come up with after months of toil on a non-trivial application structure. That said, I'm kind of excited about what I've created here. I'm really enjoying the folder structure and the naming conventions. I find the files easy to find and to organize. And, I think this will be the approach that I use on my next Router-related exploration.

When I started this exploration, I fully intended to include delightful view-based animations. However, the scope of the exploration quickly got away from me. And, I realized that if I included animations before sharing this, it would never get done. What I'd like to do now is go back and add those animations as a subsequent exploration as I suspect that animations will be equally fraught with peril and tangential spelunking.

And, of course, if anyone has any feedback, I'm all ears. I'm just feeling around in the dark here; so, expert guidance is always appreciated!



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

Hi, you are amazing. Thanks for you stuff. I'm little disapointed, this project without lazy load modules, i'm right?

Reply to this Comment

@Shwart,

Thank you for the kind words. You are correct that this does not have lazy-loading modules. I am not entirely sure if they are possible with this approach .... but they *might* be. I haven't gotten that far in my learning yet. It was quite an effort just to get this far.

Reply to this Comment

Great work Ben and a valuable learning resource too.

To get it working was quite an achievement, the invison folks must be quite impressed I'd imagine too!

Many thanks
Tom

Reply to this Comment

@Tom, @Alboh,

Thank you very much. It was exciting to work on; though, I am afraid that this falls on deaf ears at work. While much of our earlier InVision app was built in AngularJS 1.x, many of the teams have moved onto to using ReactJS for their newer work. Which, frankly, I don't understand. I've played around a bit with ReactJS, and I just don't see why one would _move_ to it from Angular. I totally get why someone would _start_ with it, since it is a much smaller API (and does many fewer things); but, once you know Angular, I struggle to see the appeal of moving to a framework that doesn't support dependency-injection.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
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.