The Guide to Building Quality Angular 2+ Components

During Wishtack’s coachings and trainings, we noticed that when using modern web frameworks and libraries like Angular / React / Vue, developers are not mostly struggling with the syntax or the features.

The main issue is how to split code and responsibilities between components. How many components do I need, what should they do, how, when etc…
Of course, there’s no generally applicable answer for these questions… but there are some best practices and that’s what I’ll try to share with you in this post.

We will focus on Angular but same logic applies to any other framework or library.

The 3 component categories

First of all, we will split components into 3 categories: View components, Container components and Dumb components. It’s a hierarchy that starts from top with the View Components and ends at the bottom with Dumb Components.

View Components (or Routing components)

View components are the components at the top of the hierarchy. They are entry components commonly injected in the DOM by the router.

They should be the only components allowed to read the route parameters and decide which data should be loaded and displayed…
…but they should not load the data or display it. It’s out of their scope.
This will be handled by their children and grandchildren.

One other rule is that these components should not be in the same module as other feature components, services etc…

As view components are highly bound to the routing, they should be in dedicated routing modules.

@Component({
    selector: 'wt-wishlist-view',
    template: `
<wt-wishlist-container [wishlistId]="wishlistId"></wt-wishlist-container>
`
})
export class WishlistViewComponent implements OnInit, OnDestroy {

    wishlistId: string;

    private _subscription: Subscription = null;

    constructor(private _activatedRoute: ActivatedRoute) {
    }

    ngOnInit() {
        this._subscription = this._activatedRoute.params
            .map((params) => params['wishlistId'])
            .subscribe((wishlistId) => this.wishlistId = wishlistId);
    }

    ngOnDestroy() {
        /* Even though it's not necessary here because <code>ActivatedRoute.params</code> doesn't need to be unsubscribed from.
         * But things can change if you use switchMap or mergeMap...
* In addition to this, it's a good habit 🙂 */
        if (this._subscription !== null) {
            this._subscription.unsubscribe();
        }
    }

}

Or even shorter using the async pipe:

@Component({
    selector: 'wt-wishlist-view',
    template: `
<wt-wishlist-container [wishlistId]="wishlistId$ | async"></wt-wishlist-container>
`
})
export class WishlistViewComponent implements OnInit {

    wishlistId$: Observable<string>;

    private _subscription: Subscription = null;

    constructor(private _activatedRoute: ActivatedRoute) {
    }

    ngOnInit() {
        this.wishlistId$ = this._activatedRoute.params
            .map((params) => params['wishlistId']);
    }

}

Note the $ suffix convention that tells that wishlistId$ is an observable emitting wishlistIds​.

Be careful though. Don’t get used to the async pipe. It should be handled with care.

In this particular case, it’s just awesome but in other cases, if the observable is coming from http.get or some other similar source, using the async pipe twice would trigger the request twice.

Container Components (or Smart components)

Container components are responsible of loading and updating data coming from stores and ReSTful APIs. They are configured using inputs.

They should never try to read the parameters from the activated route directly as they might be used in other views or even other applications. In addition to this, if you do so, it will not be trivial to find which components to update if you change your routing configuration (route parameter names for example).

@Component({
    selector: 'wt-wishlist-container',
    template: `
<wt-wishlist
        [wishlist]="wishlist"
        (onWishlistChange)="updateWishlist({wishlist: $event})"></wt-wishlist>
`
})
export class WishlistContainerComponent implements OnInit, OnChanges, OnDestroy {

    @Input() wishlistId: string;

    wishlist: Wishlist;

    constructor(private _wishlistStore: WishlistStore) {
    }

    ngOnInit() {
        this._retrieveWishlist();
    }

    ngOnChanges(changes) {
        if (changes.wishlistId != null) {
            this._retrieveWishlist();
        }
    }

    updateWishlist({wishlist}: {wishlist: Wishlist}) {
        this._wishlistStore.updateWishlist({wishlist: wishlist})
            .subscribe((_wishlist) => this.wishlist = _wishlist);
    }

    private _retrieveWishlist() {

        if (this.wishlistId == null) {
            this.wishlist = null;
        }

        this._wishlistStore.getWishlist({wishlistId: this.wishlistId})
            .subscribe((wishlist) => this.wishlist = wishlist);

    }

}

 

And now with the proper observables subscription management logic.

@Component({
    selector: 'wt-wishlist-container',
    template: `
<wt-wishlist
        [wishlist]="wishlist"
        (onWishlistChange)="updateWishlist({wishlist: $event})"></wt-wishlist>
`
})
export class WishlistContainerComponent implements OnInit, OnChanges, OnDestroy {

    @Input() wishlistId: string;

    wishlist: Wishlist;

    /* We'll put all our subscriptions here so they will be cleaned up automatically by `ngOnDestroy`.*/
    private _subscriptionDict = {
        update: null,
        wishlist: null
    };

    constructor(private _wishlistStore: WishlistStore) {
    }

    ngOnInit() {
        this._retrieveWishlist();
    }

    ngOnChanges(changes) {
        if (changes.wishlistId != null) {
            this._retrieveWishlist();
        }
    }

    ngOnDestroy() {
        for (let subscription of Object.values(this._subscriptionDict)) {
            this._clearSubscription(subscription);
        }
    }

    updateWishlist({wishlist}: {wishlist: Wishlist}) {

        /* Cancel the previous update. */
        this._clearSubscription(this._subscriptionDict.update);

        this._subscriptionDict.update = this._wishlistStore.updateWishlist({wishlist: wishlist})
            .subscribe((_wishlist) => this.wishlist = _wishlist);
    }

    private _retrieveWishlist() {

        this._clearSubscription(this._subscriptionDict.update);

        if (this.wishlistId == null) {
            this.wishlist = null;
        }

        this._subscriptionDict.wishlist = this._wishlistStore.getWishlist({wishlistId: this.wishlistId})
            .subscribe((wishlist) => this.wishlist = wishlist);

    }

    private _clearSubscription(subscription) {

        if (subscription !== null) {
            subscription.unsubscribe();
        }

    }

}

Dumb Components

Dumb components should only handle the display and UI. Data should come from its inputs and actions should be sent to the parent container component through outputs.

This kind of components will generally have more HTML and CSS logic than TypeScript business logic.

@Component({
    selector: 'wt-wishlist',
    template: `
<div>
    <span>{{ wishlist.name }}</span>

    <form (ngSubmit)="updateWishlist({formGroup: formGroup})">
    ...
    </form>

    </div>
`
})
export class WishlistComponent {

    @Input() wishlist: Wishlist;
    @Output() onWishlistChange = new EventEmitter<Wishlist>();

    formGroup: FormGroup;

    updateWishlist({formGroup}: {formGroup: FormGroup}) {

        let wishlist: Wishlist;

        // ... converting fromGroup to Wishlist object

        this.onWishlistChange.emit(wishlist);

    }

}

 

Summary

To summarize this, here’s a table for dispatching the responsibilities on the different components families.

angular-components-responsibilities-table

 


3 thoughts on “The Guide to Building Quality Angular 2+ Components

  1. Hi there, thank you for this well summed up guide !
    Concerning this remark: “Be careful though. Don’t get used to the async pipe. It should be handled with care.”
    There is a neat solution implemented in Angular v4 to solve this problem. In a structural directive you can use the “as” keyword to assign the evaluated result of the observable to a local variable. You can then use this variable as much as you want without triggering a new subscription to the observable.

    ….

    More information here : https://nitayneeman.com/posts/using-single-subscription-for-multiple-async-pipes-in-angular/

    Like

Leave a comment