The Principles for Writing Awesome Angular Components

Giancarlo Buomprisco - Oct 8 '19 - - Dev Community

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
Enter fullscreen mode Exit fullscreen mode

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 
    }
Enter fullscreen mode Exit fullscreen mode

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;
            }
          }
       });
    });
Enter fullscreen mode Exit fullscreen mode

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();
             }
        });
    }
Enter fullscreen mode Exit fullscreen mode

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();
    }
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

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

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . .