DEV Community

loading...
Cover image for The Principles for Writing Awesome Angular Components

The Principles for Writing Awesome Angular Components

gc_psk profile image Giancarlo Buomprisco Originally published at blog.bitsrc.io ・10 min read

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

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!

Discussion (11)

pic
Editor guide
Collapse
vitale232 profile image
Andrew Vitale

Thanks for such a great article!

I'm new to Angular, and I find that I'm drawn to using a small service to communicate between components rather than the @Input/@Output method you described. Can you explain why I/O is preferable?

Collapse
brooksforsyth profile image
Brooks Forsyth

I find using input output for view only components to be simpler. If I have a parent component whos child component is a button. The input could be the button text and the output would be emitting the click event. Then I can use that button everywhere and customize it.

If I made a service to keep track of button clicks it would require a new file and for that component etc...

I do think passing data thats more complex than that should be handled in a service.

Collapse
gc_psk profile image
Giancarlo Buomprisco Author

I agree, your comment is on point!

I would stress that ideally a dumb component should never access a service for state.

Of course I am quite sure there are situations where a service could be needed

Thread Thread
brooksforsyth profile image
Brooks Forsyth

haha I had dumb component written but changed it to view only component. I felt as if dumb may be offensive. Im not sure why components don't have feelings =)

Thread Thread
vitale232 profile image
Andrew Vitale

Thanks to you both. The considerations for I/O vs a service make much more sense to me now. Like most thing in programming (and life), it depends!

Collapse
gc_psk profile image
Giancarlo Buomprisco Author

Hi Andrew!

I think it depends a lot on what data you're passing. First of all, if your component is dumb, then it shouldn't require a service. That's where it's ideal to use inputs and outputs for communication. Watch our for this kind of situations.

If your container requires some state from a service, then by all means that's exactly how it should be done.

Collapse
mustapha profile image
Mustapha Aouas

Great article thanks for sharing

Collapse
gc_psk profile image
Giancarlo Buomprisco Author

Thanks for reading it! :)

Collapse
albertomontalesi profile image
AlbertoM

As a junior angular developer I really like your articles, thanks for sharing

Collapse
gc_psk profile image
Giancarlo Buomprisco Author

Thanks a lot! I hope you'll enjoy the next ones too!

Collapse
manueledones profile image
Manuele Dones

Good recap article!
Other tips I may suggest are the following:
-Do not be scared by using Content Projection. In my experience developers do not use it so much, they instead tend to add a lot of @inputs to cover all the possible configurations...
-Spend time to read how Angular Material /PrimeNg/ etc have implemented their components,I always find it effective.
-Add strict mode true to the typescript tsconfig.