July 07, 2017

Self-containment challenge in Angular

I didn't plan on writing too much about web development here, but for the past months I've been learning modern web frameworks/techonologies and I've found something worthy writing about regarding Angular.

In Angular, as in other well designed framework you are expected to separate concerns as much as possible. For instance, Components should be as reusable as possible. One feature for such reusability is self-containment. That is, while sometimes you need their host component to interact with them beyond their APIs (Inputs, Outputs, etc.), you need to move in a slightly lower layer for things like adding/removing CSS classes. But you can take measures so everything is under control.

Nonetheless, you can see yourself in trouble with the markup. Due to the rigidity of HTML, certain elements must be direct children of their 'semantic ancestors' if you don't want the browser to make a mess out of your markup. For instance, a <tr> must be a direct child of a <table>, or at least of a <thead>, <tbody> or <tfoot> —among a few others that don't alleviate the issue—. The same holds for lists; for instance an <ul> must contain <li>s.

Let's look at the problem with a concrete example.

Our problem

In our example we want to display a table, each row showing a member of The Beatles, with his name and the title of a song written by him:

We have defined the following simple model (app/beatle.model.ts):

export class Beatle {
constructor(public name: string, public song: string) {}
}

As we are planning on adding certain functionality to the rows, we have decided to model the table and the row as separate Component. So on one hand we have the main component —the table— (app/app.component.ts):

import { Component } from '@angular/core';
import { Beatle } from './beatle.model';

@Component({
selector: 'app-root',
template: `
<table>
<tr>
<th>Name
<th>Song
</tr>
<app-beatle-row *ngFor="let beatle of beatles" [beatle]="beatle"></app-beatle-row>
</table>`,
})
export class AppComponent {
// Some hardcoded data
beatles: Beatle[] = [
new Beatle('John', 'A Day in the Life'),
new Beatle('George', 'While My Guitar Gently Weeps'),
new Beatle('Ringo', 'Octopus\' Garden'),
new Beatle('Paul', 'Hey Jude'),
];
}

And on the other hand the row component (app/beatle-row-component.ts):

import { Component, Input } from '@angular/core';
import { Beatle } from './beatle.model';

@Component({
selector: 'app-beatle-row',
template: `
<tr>
<td>{{beatle.name}}</td>
<td>{{beatle.song}}</td>
</tr>
`,
})
export class BeatleRowComponent {
@Input() beatle: Beatle;
}

Approach A: The intuitive solution

Well, this is what we may try at first by using our intuition. It's what is shown in the code. But I've added a section for it so this post looks nicer 🙂.

The problem is, well, it just does not work:

Something is preventing the browser from rendering a proper table. If we inspect the DOM, we'll notice something like this:

<table>
[...]
<app-beatle-row>
<tr>
<td>John
<td>A Day in the Life
</tr>
</app-beatle-row>
[...]
</table>

Oh, our custom tag <app-beatle-row> is wrapping the <tr>. That's invalid HTML and the browser gives up.

Approach B: <ng-container>

Given our failure, we know we need a way of getting rid of that annoying intermediate element. Good news is that Angular provides us with an interesting tag named <ng-container> which does exactly that: not being made a DOM element from it. This looks like what we need, doesn't it?

We (desperately) move the *ngFor to a <ng-container> that wraps the <app-beatle-row> (app/app.component.ts):

[...]
<ng-container *ngFor="let beatle of beatles">
<app-beatle-row [beatle]="beatle"></app-beatle-row>
</ng-container>
[...]

The result? Exactly the same as before.

This approach has proven to be a very silly one. Yet the <ng-container> effectively isn't added to the DOM in a meaningful way, as long as the row component is still modeled as an element, we'll get its corresponding tag wrapping the <tr>.

Approach C: Implementing the row as a Directive

First of all, a Directive is meant to affect somehow the behavior of an element so our row doesn't look as a good candidate for it.

But anyway, let's do it as an experiment. In the main component we would need this (app/app.component.ts):

[...]
<ng-container *ngFor="let beatle of beatles">
<ng-container *appBeatleRow="beatle"></ng-container>
</ng-container>
[...]

We need a nested container since in Angular we cannot apply multiple directives to the same element. Not much of a trouble.

Next, we would refactor the row component as a directive (app/beatle-row.directive.ts):

import { Directive, ElementRef } from '@angular/core';
import { Beatle } from './beatle.model';

@Directive({
selector: '[appBeatleRow]',
})
export class BeatleRowDirective implements OnInit {
@Input() appBeatleRow: Beatle;

constructor(private element: ElementRef) {}

ngOnInit(): void {
const tr = document.createElement('tr');

const tdName = document.createElement('td');
tdName.appendChild(document.createTextNode(this.appBeatleRow.name));
tr.appendChild(tdName);

const tdSong = document.createElement('td');
tdSong.appendChild(document.createTextNode(this.appBeatleRow.song));
tr.appendChild(tdSong);

this.element.nativeElement.parentNode.appendChild(tr);
}
}

A few notes on this:

  • We are manipulating the DOM directly, which in Angular is an anti-pattern. We should have implemented this via Renderer2, adding one more injected parameter to the constructor, therefore making the code even more complex.
  • The provided ElementRef —the element that the directive is applied to— is referencing a comment node, which is what <ng-container>s get rendered to. That's why we need to add the table row to its parentNode instead, to avoid the following exception: DOMException [HierarchyRequestError: "Node cannot be inserted at the specified point in the hierarchy".

So this approach is bad design, an abuse of a building block, cumbersome for what it does and, what is worse: it doesn't handle a change of the data so in the case some Beatle decided to change his name (excentric people often does), the user wouldn't see it in the browser. So we would need to add even more code to make sure everything works.

Approach D: Getting crazy

Now we think there must be some construct/directive/whatever that does the trick. So we start researching and find information about TemplateRef, NgComponentOutlet and others.

We might achieve our goal with them, but with a lot of added complexity so they won't serve our purpose either, given we are pursuing the simplest and self-explanatory design possible.

Approach E: Using an attribute selector

Let's look for a simpler solution. What about letting the selector of our row component be an attribute selector instead of an element selector? (app/beatle-row-component.ts):

[...]
@Component({
selector: '[app-beatle-row]',
template: `
<td>{{beatle.name}}</td>
<td>{{beatle.song}}</td>
`,
[...]

Notice we have also removed the <tr> as the intended usage now is: this component is the content of any table row having the attribute app-beatle-row.

At last, we update our table component to use it the way it's meant to be used now (app/app.component.ts):

[...]
template: `
[...]
<tr app-beatle-row *ngFor="let beatle of beatles" [beatle]="beatle"></tr>
[...]

Let's see what happens:

It works!

…But our joy is capped by two facts:

  • Our markup seems to be forcefully split. We'd ideally want our main component to use the row by its custom tag without having to know it's actually a <tr>. In other words, that fact is an implementation detail and we want our components to expose as little as possible.

    In addition, the child component may need to add some stuff to the row element and, while it can still interfact with its host element through @HostBinding and @HostListener, we feel that would be a workaround rather than a design decision.

    By the way, I've put links to the docs for both directives, but there is nothing useful written there at the moment.

  • TSLint, with default settings, emit a warning about the attribute selector: The selector of the component "BeatleRowComponent" should be used as element (https://goo.gl/llsqKR) (component-selector)

    If we browse to the provided URL they tell us, more or less, that elements stand out more than attributes in the markup so Components are more naturally and desirably modeled as elements (tags).

    But is there any way of doing that while also avoiding the 'table-disrupting' tag?

Conclusion

Approach E, using an attribute selector, is the best option. It has certain downsides but fewer than other options. It's the idiomatic way of doing this in Angular.

But this may change in the future as a new <ng-host> element is being considered by the dev team and it may feature the functionality of skipping the element by which the parent element (the host) is including the child one. If you are interested in following that, a good starting point is this discussion on GitHub.

By the way, the old AngularJS had some mechanism (namely, replace: true) that allowed something similar, but they found it to be unreliable and decided to deprecate it. So currently core Angular has no replacement for it (pun not intended 🙂).

Let's hope <ng-host> or some other mechanism makes its way into this great framework!

If you know of some way I haven't discussed here or any third-party package which does the trick, please share it in the comments!

No comments:

Post a Comment