7 Deadly Sins of Angular
Armen Vardanyan
Posted on March 23, 2021
Original cover photo by Nick Gavrilov on Unsplash.
Angular is known to be an opinionated and prescriptive framework. Despite that, it has footguns like every other technology. In this list, we review the most common and deadly sins that are committed in Angular applications. You will learn how to make amends to redeem your eternal Angular soul from damnation.
For this article, we created a rating system that categorizes each Angular sin based on the severity of its impact and the precise influence it has on an Angular codebase. We value the sins based on how they affect:
- Potential for bugs
- Maintainability
- Architecture
- Performance
- Scalability
- Bundle size
- Accessibility
- Code reuse
#7: Eagerly loading all features
Not utilizing lazy loading in our applications is a colossal sin, particularly because lazy loading
- Is simple
- Is built-in
- Provides very tangible performance and network usage improvements
In short, use lazy loading where applicable by carefully dividing your application into logically sound modules that incorporate related logic, then load those modules lazily.
Amends: Either use the Angular Router's lazy loading feature or use the function-like dynamic import statement.
#6: Grouping classes by type
We have often seen codebase with folders called services, pipes, directives, and components inside an Angular application. On the surface, this might look reasonable: After all, if I am looking for some service, it makes sense to look for it under a folder named services. But in reality, this poses several problems:
- The type grouping folders end up as junk drawers of unrelated classes that are hard to navigate.
- Working on a component that uses the service also requires navigating to a very distant folder. This is a violation of the Principle of Proximity which states that files that often change at the same time should be located closely.
- Makes our applications less scalable: If all our services, directives, pipes, and components are dumped in the same directories, it means more refactoring.
So how do we solve this? Here are some tips:
- Group by feature first, then by layer, then finally maybe by type.
- If a service is relevant to an Angular module, place it inside that module first.
- Maybe create a submodule if the module is large enough.
- Then the most basic module can have a services folder which contains services only relevant to that module.
A relevant example is an admin module that contains submoldules that allow the user to manage companies and users that are associated with them. It is natural to create a "users" module and a "companies" module, and provide the "UserService" and the "CompanyService" in the respective modules. But imagine now we need to display a dropdown with company names in the user detail page, so we can add that user as an employee to some company. Obviously we have to use the "CompanyService", but it is inside the "CompanyModule". So what we need is move it up into the "AdminModule", so both modules can have access to it. We will then do similar refactorings in all such relevant scenarios.
Here is a nice folder structure that resembles a good approach to frontend architecture from this example:
├───app
│ │ app-routing.module.ts
│ │ app.component.ts
│ │ app.module.ts
│ │
│ ├───admin
│ │ │ admin.component.ts
│ │ │ admin.module.ts
│ │ │ admin.routing.ts
│ │ │
│ │ ├───companies
│ │ │ companies.component.ts
│ │ │ companies.module.ts
│ │ │ companies.routing.ts
│ │ │
│ │ │───services
│ │ │ companies.service.ts
│ │ │
│ │ └───users
│ │ │ users.component.ts
│ │ │ users.module.ts
│ │ │ users.routing.ts
│ │
│ │───services
│ │ users.service.ts
│ │
│ └───common
│ │ common.module.ts
│ │
│ ├───directives
│ │ error-highlight.directive.ts
│ │
│ ├───pipes
│ │ includes.pipe.ts
│ │
│ └───services
│ local-storage.service.ts
You can find the example app here.
#5: Subscribing manually to an observable
In Essence, subscribing to an Observable manually means performing imperative logic. Why would anyone subscribe to an Observable manually anyway? If it is not to perform an imperative action, then it is useless. If we can express the same thing using RxJS operators in a more declarative way, then there is no need to subscribe to an Observable; we could just use the AsyncPipe
. However, notice that the AsyncPipe
does not handle errors and completions Rule of thumb: Only subscribe to an Observable manually if you need to perform an imperative operation that cannot be done in another way. A very common example of that is enabling/disabling a FormControl
depending on the latest emission from an RxJS stream. It can only be done using FormControl
's enable/disable methods, which are imperative by themselves, thus the need to subscribe.
#4: Big, hairy components
Imagine a whole Angular application in one component. Are you laughing? We've seen this. The same reasons for this being a deadly sin, applies to components at a smaller scale as well. Do you have one component per feature or per page? You're doing it wrong!
With an entire feature in just a single component, you're giving Angular a hard time keeping performance high since every change causes all data bindings to be re-evaluated and dirty-checked. What's worse, you leave this unmaintainable mess for your co-workers or your future self.
There are several reasons why a component can grow too big. It can be dealing with too many responsibilities. Ideally, components should be thin wrappers gluing user interactions and application events together with the UI.
So in essence, there are things that our components should and should not do. Here are some things a component should do :
- Work with the DOM
- Display data from store/services
- Handle its lifecycle events
- Manage forms (template-driven/reactive)
- User interactions
- Pass data to child components
Things a component should not do:
- Directly load data
- Modify global state
- Work with storages directly (cookies, localStorage, etc)
- Work with real time connections directly (WebSockets and more)
- Handle custom DOM-related scenarios (for example, highlighting invalid inputs). Those can be extracted to services to be more reusable.
Variation: Big, hairy services
- Sometimes we fail to organize our services correctly.
- Usually, services dealing with external data (loaded by HTTP, for example) are to be sorted by feature.
- But sometimes logic gets mixed. For example, a service called ArticleService might start making HTTP requests that create/update bookmarks or tags. That is a clear violation of the Single Responsibility principle. Good examples of what an ArticleService should do are adding an article to a database, deleting it, getting/sorting/filtering a list of many articles, essentially, CRUD (create, read, update, delete).
- To avoid situations like this, always categorize your services based on which data features they work with, and don't mix them with services that provide abstraction layers, for example an adapter for a third-party library.
#3: Putting complex logic in component templates
While declarative component templates are nice, they shouldn't be used for complex logic, presentational or otherwise. Strict template type-checking removes silly mistakes such as type errors or typos.
Placing logic in component templates forces you to test it through the DOM. Component tests are slower than unit tests because the component template needs to be compiled and a lot of setup happens. Additionally, logic placed in component templates cannot be reused.
At the very least, extract logic from a component template into the component model.
However, you're better off extracting all forms of logic into services. Presentational logic belongs in a presenter. Non-presentational logic belongs in other service types. Read #4: Big, hairy components for more on this topic.
#2: Putting all declarations in AppModule
Frankly speaking, modules are probably the most heavily criticized feature of Angular. They are hard to explain to newcomers, sometimes difficult to maintain and an overall source for confusion. So one really bad idea would be to put all of our imports/exports/declarations directly into our root AppModule. This not only violates the principle of separation of concerns, but also makes the AppModule insanely bloated the more complex our application gets. But thankfully, there is a relatively easy solution to this
- Create Feature modules and separate different feature component declarations into them
- For components/pipes/directives/services used by different modules create a Shared Module
But the second bullet point can also become a bit sinful if we start
Variation: Putting too many declarations in SharedModule
To avoid this, we might start grouping dependencies inside Feature modules too. For example, if we have an AdminModule, which contains UserModule and AccountModule, and both those modules use a service called ManagementService, we can move that service to be inside AdminModule rather than the entire application module; this way, Feature Modules can have their own Shared Modules
#1: Using imperative programming and default change detection
Some sins are understandable. Despite being built around RxJS, Angular itself still encourages imperative programming: the state is an object that we can freely modify as we see fit, and Angular Change Detection will update the DOM accordingly. But there are multiple problems with this approach:
- Imperative programming is too verbose and hard to understand; very often one would have to read an entire passage of code to get the idea how a state of data is modified
- Imperative programming is built around mutating state: an object under the same reference gets mutated all the time, which can become a constant source of weird bugs: your state has changed, but you have no idea how and from where!
- Default Angular Change Detection is more or less efficient, but it still makes lots of unnecessary steps, which we can easily skip
There are several ways to redeem this particular sin:
- Most importantly, ditch imperative programming in favour of declarative, use the best practices of functional programming, write pure functions, be very explicit, use composition, avoid bad practices
- Use more and more RxJS Observables, operators, and start describing your states and its mutations as streams
- Stop mutating data manually, switch to ChangeDetectionStrategy.OnPush, use Observables together with the async pipe
- Also consider using a State Management System like NGRX
Conclusion
There are lots of things that can go wrong when developing a frontend application; this guide was meant to showcase the most common and important things developers tend to do in bad ways when using Angular. Hopefully, when you review your applications and remove some of the sins that might be present there, you will end up with a more scalable, understandable and manageable codebase
Posted on March 23, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.