Summarizing...
If you're using angular, I assume that you like its CLI opinionated project structure. It's well designed to give you a good start point for a web application. At the same time, it's flexible enough to give you the freedom to do all sorts of crazy assemblies. If you, at the moment, don't care about how you're doing things just because you feel comfortable in a supposedly safe directory structure built by Angular CLI, chances are that, in the long term, you'll end up with an unmaintainable slow web application haunting your life.
What could go wrong?
It's not uncommon to underestimate the potential of a web app during the time it's being built. Once it is released and people start using it, features requests are almost unavoidable. Have you missed something during your app specs? Even though a poor analysis is usually the reason for an incomplete final product, this is not always the root cause of this kind of situation. The list of what could possibly take your original app to this undesirable stage is larger than just one single poor-analysis factor. Maybe the application was formerly designed to quickly fulfill a single tiny specific demand in the company where you work. Or maybe the client changed his/her mind after seeing the released product; or maybe it was just a pet project that started showing some real potential. It doesn't matter right now what is pushing you to change your original application. In this post, let's focus on the product developing practices, instead of concentrating on the reasons that led it to its current (messy?) state. Let's talk about how you can build or refactor your app, even if it is a small one today, to make it less painful to expand it in the future. We'll be focusing on an @angular web app. We'll see a few good practices that, if adopted from the beginning, can keep your application architecture healthy. Despite the fact that they are kind of overwhelming in small apps (IMO), they can be your life-guard in the future.
What we'll be talking about
There are some actions you can take to keep an @angular app healthy as time goes by. This article does not intend to be an exhaustive list of good practices, but a shortlist of those that I think are the most important of them. We'll be talking about lazy loading, state management, change detection, components architecture and schematics. And the kind of situations a wrong decision involving these topics can bring to the final product. I'll try to reference relevant articles and pages about each topic as we go through each one of them.
Lazy Loading Modules
This is a UX tool available for your app. As your app grows, you can just split it up in several modules.
Any component with some complexity above the minimum should be declared in its own module. I'm calling "complex" any component that you have to spend 200+ lines (not taking comment lines into account) of code to make it works. Maybe a form with only two fields doesn't need to reside in its own module, but a popup list with a searching feature certainly does. By declaring them in their own modules, you can import more selectively what you take into your app. Of course, there's no need for lazy loading complex components. The real advantage of lazy loading is on feature modules.
The problem: if you don't lazy load your feature modules (virtually all of them), you are forcing all of your users to download all the files of your application even if they won't ever use most of the features being downloaded to their browsers. We're talking about the application first rendering delay, that time spent from the moment the user sends a request to the server to the time he/she sees something in the browser. I'm not talking just about that splash screen, but about what comes after it. Instead of forcing the users to stare at a splash screen like forever waiting for the first useful module to be loaded, you want your app to be ready to use ASAP.
The solution: Letting the user initially download just what he needs at first is a bandwidth saver. This can be a very sensible aspect on devices with a poor internet connection. Maybe you want to initially send just that login-page component and nothing more. As soon as the user can see that component you can setup @angular to automatically download (in the background) the next modules that he/she will probably want to see after logging in. Of course, you can opt to wait for the user to finish the login process before start downloading anything else. Or you can take the lazy loading to another level by analyzing the user's average behavior using some statistical tool to try to predict what parts of the app your users will probably want to see after any visited page, by using Minko's Gess.js like he describes in this great article.
Caveats: We are talking exclusively about UX, not about security. Developers tend to believe they are increasing security by using lazy loading and angular routes guards. The ability to load a module just after you're sure the logged user has the right permissions to do it doesn't mean you're protecting anything. You shouldn't rely on the front-end features at all to secure your app. The back-end is the right place to take your security actions because it's much harder for an attacker to have access to it in order to mess up with your system. On the other hand, the front-end code is fully available to be explored. One more persistent user with some javascript knowledge will find out how to reveal hidden components or use your REST API from Postman or any equivalent tool to try to gain access to your database and other protected resources.
Recommended Reading: You can learn a lot about lazy loading from the links below.
- Setting up lazy load modules
- Asynchronous routing
- Route-level code splitting in Angular
- Yes, you can load individual components lazily too
Get used to loading all of your feature modules lazily. By doing that it'll be easier to control what your users need to download when they use your web app.
State Management
This is a consistency tool in some situations and a UX one in others. As your app grows, you'll need to show the same information in (potentially) various formats to the user. For example, you can let the user enter a new address in a profile form and want that address to be instantly available on a list shown in an area on the page just reserved to show existing addresses or to be available in a selection list immediately
The problem: if you have the same information spread all over the place in your app, sooner or later you'll forget to update it somewhere and you'll be face-to-face with a problem similar to the wrong-messages-count one that led facebook to adopt redux. You want the information presented to your user in various forms to be consistent. This is one problem. The other situation is about UX. Suppose you have this really long form. Suppose a user is in the middle of the task of filling up that form. Suppose that, for any reason, the user needs to navigate away from the form page (not just hiding it, but destroying it). Finally, suppose the user navigates back to the form: would it be a good UX if he/she find out that the already fulfilled information was all cleared up and it's necessary to start filling up the form all over again?
The solution: have a state object to store all your app components' currently used values. One single object containing all the information, without duplicates. If you google it you'll find out that this single object is called "single source of truth" and it's basically an object that can be globally accessed by any component. It's also important to notice that it's easier to add developers to a project if you follow (well-established) patterns. Of course, you could build this global state object by yourself, using reactive functions from the rx.js
library, like BehaviorSubject's, ReplaySubject's, etc. Then you'd have to write functions to isolate the pieces of the state object you wanted in each part of your app. Soon you'd need functions specialized in updating the state object. And you'd have to decide whether mutate it was a better idea than just create a new object. Next, for the sake of organization, you'd decide to have functions to be run as a consequence of the state object updates. Then you'd finally realize you'd be reinventing the wheel and that you should have adopted, from the beginning, NGRX, NGXS, Akita or any other stable library that do all of what you'd have been coding for months just out-of-the-box.
Caveats: the amount of code you have to write to get a state object up and running can be intimidating. It's not avoidable and let's face it: it's really a lot of work to do in terms of coding, apparently repeated (although it's not really duplicated code despite the fact it appears to be so). But it has been a while since coding a lot became part of a developer's job. I have a clear impression that in the past IDEs did a lot of the front-end job. Today it seems that the heavy lifting has been transferred to the programmer, so... what are we complaining about? It's a lot of code that will save us from a lot of known issues that can be irrelevant and easy to deal with while the app is small, but will certainly bring up terrible headaches when it becomes a large web app.
Recommended Reading:
- Understand Redux by building your own Store
- NGRX Store: Understanding State Selectors
- Video series about NGRX
- NGRX
- NGXS
- Akita
OnPush Change Detection Strategy
One of the reasons @angular
has been originally designed to be a completely different project from angular.js
, is that the latter's two-way data flow used frequently by the change detector. In @angular
, you're not forced to use two-way data binding, so an update in a child doesn't necessarily cause an update on the data in a parent component (you can use @Input()
and @Output()
decorators instead of "banana-in-a-box" notation to exchange data). But the two-way data binding alone is not a big problem. Think of a page full of components, having a large table and lots of animating things triggered by user interactions. Now imagine @angular
having to figure out which parts of that page (comprised of lots of components) must be updated so the view can reflect correctly the underlying data. You, as the developer, know exactly what should be refreshed on the page whenever the underlying data changes. @angular's change detector does it's best to repeat the same task as you'd do if you could take care of the change detection process.
The problem: In the process of figuring out which parts of the view should be updated, @angular updates almost everything related to data in almost every situation. In heavy pages, this can turn the UX into a hell. The page can potentially take seconds to update every part and, in the process, disallow any interaction of the user with any part of it. Of course, you don't want to take care of selecting which components should be updated in that same heavy page, because it surely has many components being presented. But certainly, you'd wish to control this updating process at a higher level, telling @angular which groups of components (in contrast to "which components") and when they should be updated to correctly reflect the underlying data.
The solution: to have more control over when the change detection should affect your component, you can tell angular to automatically runs the change detector in some special cases (@Input()
change events, async pipes being updated and DOM originated events). This can be activated by setting the @Component
's attribute changeDetection
to ChangeDetectionStrategy.OnPush
. It will probably have no noticeable performance improvement in simple components, but I assure you it's a lifesaver when you're dealing with complex components comprised of several subcomponents. This can be the difference between a good UX and a completely unusable page of your app. If you take a look at well-designed components libraries like Google's @angular/components
you'll notice that all of the components have their change detection strategy set to OnPush
. It's an important clue of what you should be doing. All of your components should follow the same strategy (IMO). Most of the times this strategy will be enough for any component. In some specific situations not covered by OnPush
strategy, you can inject a reference to the change detector (ChangeDetectorRef
) and express your will to force a component to take part in the next turn of change detection by calling its markForCheck
method inside this component. To group your components... well, it's more simple than you imagine: you can build a component to group them, because, in @angular, everything is a component.
Caveats: most of the time, there aren't any points you should be aware of when using OnPush
(just be careful to not invalidate the OnPush strategy by too many manual calls to the change detector). I'll update this article if I remember something.
Recommended Reading:
Components Architecture (Container-Presentational)
Right now you have two important pieces of the architecture I'm suggesting: state management and OnPush
change detection strategy. You'll want to use them at their full potential and there's one thing you can do to assure it.
The problem: at this point, if you follow carefully the preceding sections, you're probably mentally set up your projects to use the above techniques. It is possible you'll not take full advantage of them and maybe the OnPush change detection strategy brings you some headaches in some situations, forcing you to manually call the change detector two many times. In addition to that, you have a component hard to test because of the reactive nature of the injected services that will make your project work.
The solution: there's a recommended way of organizing your project components in order to take maximum advantage of the configuration I've written bout until now. It's called Container-Presentational. It's important to say that this isn't general advice for every framework. We're speaking strictly of @angular
here, in the specific situation where the components are set to use the OnPush
strategy. Long story short, this is about grouping most of your components into two categories:
-
Presentational: the components in this category, as a rule, are not allowed to have any features but those related to rendering the information on the screen. The presentational components shouldn't have any injected services or access any rest API. All of the data the component needs in order to work correctly should be passed in by
@Input()
properties (which, in turn, activates the change detector, even in OnPush strategy, automatically). Similarly, all the data and requests the component needs to externalize should be done using@Output()
properties. You are allowed to inject a component service, containing methods strictly related to the rendering process or the logic of showing/hiding the component's parts. Sometimes you'll find authors using the term dumb components to describe presentational components (the smart components would be the other category, described below). - Container: the components in this category, are responsible to grab the data from external sources (REST APIs/Global State Objects) and to pass the information down to presentational components. Here you are allowed to inject as many services you think are necessary, including the State Management service. They act as the "API" components for your project. If your project is split into several libraries, the Container components would be used directly on your feature modules, and they would wrap your presentational components, passing data down to them in order to make your project work.
Caveats: sometimes it's not so obvious if a component should be container or presentational. The most obvious situation is the dialogs. On some occasions, they can be considered presentational and, in others, container components. There's not any strong rule stating what they should act like. In fact, once you're using this container-presentational organization, nobody would be arrested for deciding to inject the store service in a formerly presentational component. There are legitimate use cases where it's much more simple to inject the store in a presentational component than pass in 15 input data, which would increase the complexity of the component API. So, don't worry if you exceptionally have to inject some services in your presentational components.
Recommended Reading:
- Container Components with Angular
- Angular Architecture - Smart Components vs Presentational Components
- Angular Architecture - Container vs Presentational Components Common Design Pitfalls
- Presentational and Container Components - React Article
@angular/cli
schematics: thinking of DX
To take to your project all the things we've been talking about, you'll surely add a lot of code. If your project is already on production or in an advanced stage of development, we're also talking about refactoring (even more work to do). In any situation, when building a new project or refactoring an existing one, every developer should always think of how to reduce the amount of code needed to make things work.
The problem: adding a robust and flexible architecture to any software project usually takes many lines of code plus the creation of some files. We're talking about interfaces, abstract classes, services, DI, tests, etc. To keep your code healthy and consistent, you will add things to your code that once in a while will seem overwhelming ("OMG, this is just a hello-world component! Why do I have to create so many files/interfaces/services/modules?"). Believe-me: you should be faithful to the chosen design patterns even in these apparently overwhelming scenarios. At the same time, you should find a way to not kill the developers' experience and productivity and let them do what they are meant to do: coding. Most of the work should be around thinking of the business logic instead of writing code because there's a specific design pattern to be followed. Yes, we're talking about how to avoid the inevitable boilerplate. The design pattern should get into the project in the most possible transparent way.
The solution: there are some actions you could take to reduce the amount of boilerplate. Use good code editors configured correctly using the same setup shared among the team, for example. But I'd like to talk here about a powerful tool that comes with @angular/cli
: schematics. Schematics
is a template-based tool that comes with an API, that allows you to manipulate text files. Using schematics you are able to take one or more template files and generate custom commands that take those template files and generate other files based on them, replacing specific parts of their contents by whatever you want. Schematics
turn the boring task of creating complex directory structures in a very quick and productive task. And the best part is that building your custom schematics
is not that hard, even if you have never done anything like that before. @angular/cli
itself uses schematics
to generate components, modules, pipes, etc. What Google did here is to turn all the heavy work that @angular/cli
did internally into a powerful API. How about creating a template to, in 2 or 3 seconds, generate a feature module, with a component inside, ready to be lazy-loaded, with all the routing already configured to use @angular/material
imported as a separated module and a component service injected on your component? Try to build it and you'll see that to an experient developer who's also a fast typer, knowing absolutely what to do, it will take like 5 to 10 minutes. To a beginner developer, it would easily take 40 to 60 minutes. Building some custom schematics to help your day-by-day coding can be a good idea.
Caveats: be careful to not catch your self reinventing the wheel. The schematics won't ever be a peer dependency on your project (they're always a dev-dependency). So do not be afraid of npm install --save-dev
3rd parties schematics libraries. If their projects become abandoned, you can stop using them whenever you want without refactoring any code.
Recommended Reading:
- Schematics for Libraries
- The 7 Pro Tips To Get Productive With Angular CLI & Schematics
- Schematics: Building blocks
- Video - 2019: A Schematic Odyssey | Kevin Schuchard & Brian Love
- Global Thermonuclear Templates - Mike Brocchi
Conclusion
You should get to know all the pros and cons of the approaches you choose for your components. You'll find out that simple strategies, like the ones shown here, can help to keep your application healthy during its active life span.
Top comments (0)