The Principles for Writing Awesome Angular Components
Giancarlo Buomprisco
Posted on October 8, 2019
Introduction
This article was originally published on Bits and Pieces by Giancarlo Buomprisco
Angular is a component-based framework, and as such, writing good Angular components is crucial to the overall architecture of an application.
The first wave of front-end frameworks bringing custom elements came with a lot of confusing and misinterpreted patterns. As we have now been writing components for almost a decade, the lessons learned during this time can help us avoid common mistakes and write better code for the building blocks of our applications.
In this article, I want to go through some of the best practices and lessons that the community has learned in the last few years, and some of the mistakes that I have seen as a consultant in the front-end world.
Although this article is specific to Angular, some of the takeaways are applicable to web components in general.
Before we start — when building with NG components, it’s better to share and reuse components instead of writing the same code over again.
Bit (GitHub) lets you easily pack components in capsules so that they can be used and run anywhere across your applications. it also helps your team organize, share and discover components to build faster. Take a look.
Don’t hide away Native Elements
The first mistake that I keep seeing is writing custom components that replace or encapsulate native elements, that as a result become unreachable by the consumer.
By the statement above, I mean components such as:
<super-form>
<my-input [model]="model"></my-input>
<my-button (click)="click()">Submit</my-button>
</super-form>
What problems does this approach create?
The consumer cannot customize the attributes of the native element unless they are also defined in the custom component. If you were to pass down every input attribute, here is the list of all the attributes you’d have to create
Accessibility! Native components come with free built-in accessibility attributes that browsers recognize
Unfamiliar API: when using native components, consumers have the possibility to reuse the API they already know, without having a look at the documentation
Augmenting is the Answer
Augmenting native components with the help of directives can help us achieve exactly the same power of custom components without hiding away the native DOM elements.
Examples of augmenting native components are both built in the framework itself, as well as a pattern followed by Angular Material, which is probably the best reference for writing components in Angular.
For example, in Angular 1.x, it was common to use the directive ng-form while the new Angular version will augment the native form element with directives such as [formGroup].
In Angular Material 1.x, components such as button and input were customized, whilst in the new version they are directives [matInput] and [mat-button].
Let’s rewrite the example above using directives:
<form superForm>
<input myInput [ngModel]="model" />
<button myButton (click)="click()">Submit</button>
</form>
Does this mean we should never replace native components?
No, Of course not.
Some type of components are highly complex, require custom styles that cannot be applied with native elements, and so on. And that’s fine, especially if the native element does not have a lot of attributes in the first place.
The key takeaway from this is that, whenever you’re creating a new component, you should ask yourself: can I augment an existing one instead?
Thoughtful Component Design
If you want to watch an in-depth explanation of the concepts above, I would recommend you to watch this video from the Angular Material team, that explains some of the lessons learned from the first Angular Material and how the new version approached components design.
Accessibility
An often neglected part of writing custom components is making sure that we decorate the markup with accessibility attributes in order to describe their behavior.
For example, when we use a button element, we don’t have to specify what its role is. It’s a button, right?
The issue arises in cases when we use other elements, such as div or span as a substitute for a button. It is a situation that I have seen endless times, and likely so did you.
ARIA Attributes
In such cases, we need to describe what these elements will do with aria attributes.
In the case of a generic element replacing a button, the minimum aria attribute you may want to add is [role="button"].
For the element button alone, the list of ARIA attributes is pretty large.
Reading the list will give you a clue of how important it is to use native elements whenever it is possible.
State and Communication
Once again, the mistakes committed in the past have taught us a few lessons in terms of state management and how components should communicate between them.
Let’s reiterate some very important aspects of sane component design.
Data-Flow
You probably know already about @Input and @Output but it is important to highlight how important it is to take full advantage of their usage.
The correct way of communicating between components is to let parent components pass down data to their children and to let children notify the parents when an action has been performed.
It is important to understand the concept between containers and pure components that was popularized by the advent of Redux:
Containers retrieve, process and pass data down to their children, and are also called business-logic components belonging to a Feature Module
Components render data and notify parents. They are normally reusable, found in Shared Modules or Feature Modules when they are specific to a Feature and may serve the purpose of containing multiple children components
Tip: My preference is to place containers and components in different companies so that I know, at a glance, what the responsibility of the component is.
Immutability
A mistake I’ve seen often is when components mutate or redeclare their inputs, leading to undebuggable and sometimes unexplainable bugs.
@Component({...})
class MyComponent {
@Input() items: Item[];
get sortedItems() {
return this.items.sort();
}
}
Did you notice the .sort() method? Well, that’s not only going to sort the items of the array in the component but will also mutate the array in the parent! Along with reassigning an Input, it is a common mistake that is often a source of bugs.
Tip: one of the ways to prevent this sort of errors is to mark the array as readonly or define the interface as ReadonlyArray. But most importantly, it is paramount to understand that components should never mutate data from elsewhere. The mutation of data structures that are strictly local is OK, although you may hear otherwise.
Single Responsibility
Say no to *God-Components, *e.g. huge components that combine business and display logic, and encapsulate large chunks of the template that could be their own separate components.
Components should ideally be small and do one thing only. Smaller components are:
easier to write
easier to debug
easier to compose with others
There’s simply no definition for too small or too big, but there are some aspects that will hint you that the component you’re writing can be broken down:
reusable logic: methods that are reusable can become pipes and be reused from the template or can be offloaded to a service
common behavior: ex. repeated sections containing the same logic for ngIf, ngFor, ngSwitch can be extracted as separate components
Composition and Logic Separation
Composition is one of the most important aspects that you should take into account when designing components.
The basic idea is that we can build many smaller dumb components and make up a larger component by combining them. If the component is used in more places, then the components can be encapsulated into another larger component, and so on.
Tip: building components in isolation makes it easier to think about its public API and as a result to compose it with other components
Separate Business-logic and Display-logic
Most components, to a certain degree, will share some sort of similar behavior. For example:
Two components both contain a sortable and filterable list
Two different types of Tabs, such as an Expansion Panel and a Tabs Navigation, will both have a list of tabs and a selected tab
As you can see, although the way the components are displayed is different, they share a common behavior that all the components can reuse.
The idea here is that you can separate the components that serve as a common functionality for other components (CDK) and the visual components that will reuse the functionality provided.
Once again, you can visit Angular CDK’s source code to see how many pieces of logic have been extracted from Angular Material and can now be reused by any project that imports the CDK.
Of course, the takeaway here is that whenever you see a piece of logic being repeated that is not strictly tied to how the component looks like, that is probably something you can extract and reuse in different ways:
create components, directives or pipes that can interface with the visual components
create base abstract classes that provide common methods, if you’re into OOP, which is something I usually do but that would use with care
Binding Form Components to Angular
A good number of the component we write are some sort of input that can be used within forms.
One of the biggest mistakes we can do in Angular applications is not binding these components to Angular’s Forms module and letting them mutate the parent’s value instead.
Binding components to Angular’s forms can have great advantages:
can be used within forms, obviously
certain behaviors, such as validity, disabled state, touched state, etc. will be automatically interfaced with the state of the FormControl
In order to bind a component with Angular’s Forms, the class needs to implement the interface ControlValueAccessor:
interface ControlValueAccessor {
writeValue(obj: any): void;
registerOnChange(fn: any): void;
registerOnTouched(fn: any): void;
setDisabledState(isDisabled: boolean)?: void
}
Let’s see a dead-simple toggle component example bound to Angular’s form module:
The above is a simple toggle component to show you how easy it is to set up your custom components with Angular’s forms.
There’s a myriad of great posts out there that explain in details how to make complex custom forms with Angular, so go check them out.
Check out the Stackblitz I made with the example above.
Performance and Efficiency
Pipes
Pipes in Angular are pure by default. That is, whenever they receive the same input, they will use the cached result rather than recomputing the value.
We talked about pipes as a way to reuse business logic, but this is one more reason to use pipes rather than component methods:
reusability: can be used in templates, or via Dependency Injection
performance: the built-in caching system will help avoid needless computation
OnPush Change Detection
OnPush Change Detection is activated by default in all the components that I write, and I would recommend you do the same.
It may seem counterproductive or too much a hassle, but let’s look at the pros:
major performance improvements
forces you to use immutable data structures, which leads to more predictable and less bug-prone applications
It’s a win-win.
Run Outside Angular
Sometimes, your components will be running one or more asynchronous tasks that don’t require immediate UI re-rendering. This means we may not want Angular to trigger a change detection run for some tasks, that as a result will improve significantly the performance of those tasks.
In order to do this, we need to use ngZone’s API to run some tasks from outside the zones using .runOutsideAngular(), and then re-enter it using .run() if we want to trigger a change detection in a certain situation.
this.zone.runOutsideAngular(() => {
promisesChain().then((result) => {
if (result) {
this.zone.run(() => {
this.result = result;
}
}
});
});
Cleanup
Cleaning up components ensures our application is clear from memory leaks. The cleanup process is usually done in the ngOnDestroy lifecycle hook, and usually involves unsubscribing from observables, DOM event listeners, etc.
Cleaning up Observables is still very misunderstood and requires some thought. We can unsubscribe observables in two ways:
calling the method .unsubscribe() on the subscription object
adding a takeUntil operator to the observable
The first case is imperative and requires us to store all the subscriptions in the component in an array, or alternatively we could use Subscription.add
, which is preferred.
In the ngOnDestroy hook we can then unsubscribe them all:
private subscriptions: Subscription[];
ngOnDestroy() {
this.subscriptions.forEach(subscription => {
if (subscription.closed === false) {
subscription.unsubscribe();
}
});
}
In the second case, we would create a subject in the component that will emit in the ngOnDestroy hook. The operator takeUntil will unsubscribe from the subscription whenever destroy$ emits a value.
private destroy$ = new Subject();
ngOnInit() {
this.form.valueChanges
.pipe(
takeUntil(this.destroy$)
)
.subscribe((value) => ... );
}
ngOnDestroy() {
this.destroy$.next();
this.destroy.unsubscribe();
}
Tip: if we use the observable in the template using the async pipe, we don’t need to unsubscribe it!
Avoid DOM Handling using Native API
Server Rendering & Security
Handling DOM using the Native DOM API may be tempting, as it is straightforward and quick, but will have several pitfalls regarding the ability of your components to be server-rendered and the security implications from by-passing Angular’s built-in utilities to prevent code injections.
As you may know, Angular’s server-rendering platform has no knowledge of the browser API. That is, using objects such as document will not work.
It is recommended, instead, to use Angular’s Renderer in order to manually manipulate the DOM or to use built-in services such as TitleService:
// BAD
setValue(html: string) {
this.element.nativeElement.innerHTML = html;
}
// GOOD
setValue(html: string) {
this.renderer.setElementProperty(
el.nativeElement,
'innerHTML',
html
);
}
// BAD
setTitle(title: string) {
document.title = title;
}
// GOOD
setTitle(title: string) {
this.titleService.setTitle(title);
}
Key Takeaways
Augmenting native components should be preferred whenever possible
Custom elements should mimic the accessibility behavior of the elements they replaced
Data-Flow is one way, from parent to children
Components should never mutate their Inputs
Components should be as small as possible
Understand the hints when a component should be broken down in smaller pieces, combined with others, and offload logic to other components, pipes, and services
Separate business-logic from display-logic
Components to be used as forms should implement the interface ControlValueAccessor rather than mutate their parent’s properties
Leverage performance improvements with OnPush change detection, pure pipes, and ngZone’s APIs
Cleanup your components when they get destroyed to avoid memory leaks
Never mutate the DOM using native API, use Renderer and built-in services instead. Will make your components work on all platforms and safe from a security point of view
Resources
Thoughtful Component Design [Youtube]
If you need any clarifications, or if you think something is unclear or wrong, do please leave a comment!
I hope you enjoyed this article! If you did, follow me on Medium or Twitter for more articles about the FrontEnd, Angular, RxJS, Typescript and more!
Posted on October 8, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 1, 2024