Our content is free thanks to ag-Grid

ag-Grid is the industry leading JavaScript datagrid

ag-grid.com

Compliant components: Declarative approach in Angular

Post Editor

Angular is declarative in nature. Learn how to make your code adhere to this approach by eliminating internal manually managed states & optimize performance

11 min read
post-image

Compliant components: Declarative approach in Angular

Angular is declarative in nature. Learn how to make your code adhere to this approach by eliminating internal manually managed states & optimize performance

image
image
11 min read
11 min read

When I first heard about compliant mechanisms I was very impressed. Even though they surround us in our daily lives — we barely ever think about what they are conceptually.

These are things like backpack latches, mouse buttons, shampoo caps. In short, a compliant mechanism uses deformation to achieve its technical characteristics. In traditional engineering, flexibility is usually a disadvantage of the material. Compliant mechanisms on the contrary use it to transfer force and movement, instead of passing it through multiple moving parts as rigid body mechanisms do.

Comparing compliant pliers to traditional rigid body one

In such a system movement is very easy to predict. Because this is a single item, rather than a set of parts with hinges, springs, and joints all contributing to friction, wearing, and backlash.

Movement is precise

This precision is understandable. The movement goes through flexible connections that cannot contract or expand. So it’s always auto-corrected and guided by the whole item.

I find it to be a perfect analogy to the declarative approach in programming, as opposed to imperative in which commands manually alter the state. Just like hinges and joints pass force.

I would like to share my view on writing Angular code. It's sort of a philosophy or habit I developed over the years of working on a big UI library.

Compliant components

Angular always assumes some level of declarative code. Data bindings, event handling, and the Observable model — these all influence our style. Simple components are literally written with statements. We write what is what and how it depends on the environment instead of a set of instructions altering the state. But with increasing complexity, components might lose this quality. They turn into a series of calls, subscriptions, and stored data managing behavior. Syncing all this becomes a struggle.

The imperative approach states "if a car is a firetruck, paint it red". The declarative way would be "firetrucks are red". In Angular, this is effectively a getter. In a closer look, often the state for the component is the inputs and the rest is all calculable. To better grasp this idea, let’s create a few components utilizing this approach.

LineChart

This component is pretty straightforward at its core. We provide an array of tuples and we need to make an SVG path over those points on a 2D plane. What we want is to organize the component code so it doesn’t have imperative state manipulation. We can hook it to native SVG to have less nesting:

@Component({
  selector: "svg[lineChart]",
  templateUrl: "./line-chart.template.html",
  styleUrls: ["./line-chart.style.less"],
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: {    
    preserveAspectRatio: "none"
  }
})
export class LineChartComponent {

Besides actual data, we need to outline the area to display. This could be the viewBox itself. But it is much more user friendly to enter its properties separately and calculate it with a getter:

  @HostBinding('attr.viewBox')
  get viewBox(): string {
    return `${this.x} ${this.y} ${this.width} ${this.height}`;
  }

To make our component spicier let’s add smoothing input as well. The template would consist of a single path element:

<svg:path
  fill="none"
  stroke="currentColor"
  vector-effect="non-scaling-stroke"
  stroke-width="2"
  [attr.d]="d"
/>

We need to calculate the d attribute. One option is to have a setter on data input. But later we might want to add other related elements like fill under the line or hints for points. Instead, let’s create it with a getter as well so we will not have to manage it ourselves:

  get d(): string {
    return this.data.reduce(
      (d, point, index) =>
        index ? `${d} ${draw(this.data, index, this.smoothing)}` : `M ${point}`,
      ""
    );
  }

That’s it. The actual draw functions to make the path are irrelevant and can be googled. But read further for a live demo of all the components we are about to create complete with source code.

Media directive

The next thing I’d like to explore is a bit more complex. We want to be able to control media elements such as audio and video tags. And we want to do it with minimal imperative calls. To do so let’s create a directive. There are 3 things to control: current time, volume and play/pause state. They can also be changed through native controls so it’s going to be a two-way binding:

@Input()
currentTime = 0;
 
@Input()
paused = true;
 
@Input()
@HostBinding("volume")
volume = 1;
 
@Output()
readonly currentTimeChange = new EventEmitter<number>();
 
@Output()
readonly pausedChange = new EventEmitter<boolean>();
 
@Output()
readonly volumeChange = new EventEmitter<number>();
 
@HostListener("volumechange")
onVolumeChange() {
  this.volume = this.elementRef.nativeElement.volume;
  this.volumeChange.emit(this.volume);
}

See that @HostBinding on volume? It’s enough to get it working. But when it comes to currentTime — it changes on its own when the media is playing. So such binding would get into a messy loop updating back and forth. Instead, we need to upgrade it to setter and watch for not setting the same value again:

Input()
set currentTime(currentTime: number) {
  if (currentTime !== this.currentTime) {
    this.elementRef.nativeElement.currentTime = currentTime;
  }
}
 
get currentTime(): number {
  return this.elementRef.nativeElement.currentTime;
}
 
@HostListener("timeupdate")
@HostListener("seeking")
@HostListener("seeked")
onCurrentTimeChange() {
  this.currentTimeChange.emit(this.currentTime);
}

We will also change paused input to a getter/setter pair:

@Input()
set paused(paused: boolean) {
  if (paused) {
    this.elementRef.nativeElement.pause();
  } else {
    this.elementRef.nativeElement.play();
  }
}
 
get paused(): boolean {
  return this.elementRef.nativeElement.paused;
}

Making a video player component with such a directive is a breeze:

<video
  #video
  media
  class="video"
  [(currentTime)]="currentTime"
  [(paused)]="paused"
  (click)="toggleState()"
>
  <ng-content></ng-content>
</video>
<div class="controls">
  <button
    class="button"
    type="button"
    title="Play/Pause"
    (click)="toggleState()"
  >
    {{icon}}
  </button>
  <input
    class="progress"
    type="range"
    [max]="video.duration"
    [(ngModel)]="currentTime"
  >
</div>

With ng-content users can provide sources like they would with native video tag. And the code for the player component is shamelessly short:

currentTime = 0;
 
paused = true;
 
get icon(): string {
  return this.paused ? "\u23F5" : "\u23F8";
}
 
toggleState() {
  this.paused = !this.paused;
}

Combo Box

Now that we've got the vibe of declarative programming, let’s get into the meat and grit of things. Combo Box is a much more complex example, but don’t worry! We will not have a single function with more than one line! Well, maybe one.

During this part of the article, I would also rely on declarative preventDefault. It uses the ng-event-plugins library that I wrote about in detail in this article.

First, let’s establish the template. We will not get into creating custom controls for Angular as that is quite another topic. Instead, we will wrap it around a native input to allow users full control over it:

<combo-box [items]="items">
  <input type="text" [(ngModel)]="value">
</combo-box>

The inner template will use a label to focus the input when the user clicks on the arrow. Yes, this technique is a bit dirty. Users will not be able to add an accessible label by simply wrapping our component. But, it suffices for the sake of simplicity this time.

<label>
  <ng-content></ng-content>
  <div class="toggle" (mousedown.prevent)="toggle()"></div>
</label>
<div *ngIf="open" class="list" (mousedown.prevent)="noop()">
  <div 
    *ngFor="let item of filteredItems; let index = index"
    class="item"
    [class.item_active]="isActive(index)"
    (click)="onClick(item)"
    (mouseenter)="onMouseEnter(index)"
  >
    {{item}}
  </div>
</div>

Preventing default on mousedown events is necessary so focus never leaves the input. This component has a single @Input — an array of strings as suggestions. And this time it would have a single internal state we would manage. You might think it’s open property to show the dropdown but it’s not. It’s a currently focused suggestion index in the dropdown. We would leverage NaN as an indicator for no selected item to stick within the number type. And open is a getter to see if there is a suggestion currently selected:

get open(): boolean {
  return !isNaN(this.index);
}

We need to narrow suggestions to those that fit the user input. We can add NgControl as @ContentChild and access its value. We will then use it to filter the given array:

@ContentChild(NgControl)
private readonly control: NgControl;
 
get value(): string {
  return String(this.control.value);
}
 
get filteredItems(): readonly string[] {
  return this.items.filter(item => 
    item.toLowerCase().includes(this.value.toLowerCase())
  );
}

Now we can add another getter for the currently selected index. We will clamp it to the length of the filtered suggestion:

get clampedIndex(): number {
  return limit(this.index, this.filteredItems.length - 1);
}
 
function limit(value: number, max: number): number {
  return Math.max(Math.min(value || 0, max), 0);
}

This new getter is safe to use as it will always be within the limit of available items. Now let’s add the event handlers we had in the template. We need to toggle dropdown on an arrow click, select an item from the list, and update the current index on hover:

onClick(item: string) {
  this.selectItem(item);
}
 
onMouseEnter(index: number) {
  this.index = index;
}
 
@HostListener('keydown.esc')
@HostListener('focusout')
close() {
  this.index = NaN;
}
 
toggle() {
  this.index = this.open ? NaN : 0;
}
 
private selectItem(value: string) {
  this.control.control.setValue(value);
  this.close();
}

Next we need to handle keyboard navigation. It consists of using arrows to open dropdown and pick suggestions with the enter key. And also show the list as the user types:

@HostListener('keydown.arrowDown.prevent', ['1'])
@HostListener('keydown.arrowUp.prevent', ['-1'])
onArrow(delta: number) {
  this.index = this.open 
    ? limit(
      this.clampedIndex + delta, 
      this.filteredItems.length - 1
    ) 
    : 0;
}
 
@HostListener('keydown.enter.prevent')
onEnter() {
  this.selectItem(
    this.open
      ? this.filteredItems[this.clampedIndex]
      : this.value
  )
}
 
@HostListener('input')
onInput() {
  this.index = this.clampedIndex;
}

And that pretty much sums it up. This component is now fully operational. We wrote it in a declarative fashion only keeping track of a single state ourselves. We don’t have imperative commands like “open dropdown”. Instead we described the behavior of our component, relative to its states: control value, an input list of suggestions and a currently active one. That’s why it is called a declarative approach.

In reality you would also want to make it accessible. You can add ARIA attributes such as aria-activedescendant. This way screen readers and other assistive technologies also keep track of the selected suggestion. Read more about the combobox pattern here and here.

Ever wondered why AK-47 is such a prominent weapon of the past decades? Its construction only has 8 moving parts. This makes it easy to manufacture, operate and maintain. Same applies to software architecture. The less states you have to manage, the more robust your code is. Simple design is a reliable design. And while declarative code might not seem simple at first glance — once you get used to it, you will appreciate its neatness.

Performance

A natural question that arises — if we keep recalculating, wouldn’t performance suffer? Of course, the OnPush change detection strategy is a must. And frankly, I have never seen a case where the Default strategy was justified. Except for a field error component. As Angular forms still do not have a touched stream at the time of this writing.

To assess performance we need to pay attention to what we do in our getters. A string concatenation like we had with viewBox has a speed of about 1 billion operations per second. 300 millions on a mediocre Android phone. So it’s not a concern. Same goes for simple math operations. Things get interesting when we get to arrays and objects. Iterating over 100 items array to find an item takes 15 million ops/sec on PC and is ten times slower on a smartphone. Immutable operations that create new instances of arrays are even more taxing. Filtering a 100 items array is about 3 million ops/sec on PC and humble 300k on an Android device. Working with immutable objects is similar due to underlying JavaScript mechanics. You can check performance yourself here. This means to make compliant components a thing we need to optimize them.

Let’s add a simple memoization technique so we do not do unnecessary recalculations. We can create a decorator for pure methods. It would remember passed arguments and the last result. If arguments stayed the same — it would return the result it remembers.

export function Pure<T>(
  _target: Object,
  propertyKey: string,
  { enumerable, value }: TypedPropertyDescriptor<T>
): TypedPropertyDescriptor<T> {
  const original = value;
 
  return {
    enumerable,
    get(): T {
      let previousArgs: ReadonlyArray<unknown> = [];
      let previousResult: any;
 
      const patched = (...args: Array<unknown>) => {
        if (
          previousArgs.length === args.length &&
          args.every((arg, index) => arg === previousArgs[index])
        ) {
          return previousResult;
        }
 
        previousArgs = args;
        previousResult = original(...args);
 
        return previousResult;
      };
 
      Object.defineProperty(this, propertyKey, {
        value: patched
      });
 
      return patched as any;
    }
  };
}

This way we can refactor our code to the following pattern on a getter and a pure method couples:

get filteredItems(): readonly string[] {
  return this.filter(this.items, this.value);
}
 
@Pure
private filter(items: readonly string[], value: string): readonly string[] {
  return items.filter(item => 
    item.toLowerCase().includes(value.toLowerCase())
  );
}

Let’s benchmark our approach. We will test against plain declarative approach and imperative updates with ngOnChanges:

In this StackBlitz we have a list of 1000 components and a button to trigger change detection in all of them. You can select imperative which is effectively a dry run. Inputs do not change and nothing is happening on a change detection cycle. Declarative components have several getters. A logical one to check if a value is bigger than threshold. A string concatenation. A math getter with @HostBinding to a class. An array iterator. An array generator and an object generator. Keep in mind that all this is multiplied by 1000 because this is what each component has. And the last column has @Pure decorator on array and object operations. Here are the results averaging over a 100 change detection runs for each column:

Here are results on a smartphone:

Difference on a PC is within the margin of error while on a medium-tier Android device it is about 10%. One can look at it and say it’s 10% slower. But there’s another way to look at it. Even on the weak machine with several thousands of getters at the same run it stays within a single 60FPS frame. And it is only 1.5 milliseconds slower than basically nothing — a dry run by Angular. Typically, the real performance hog is DOM manipulations. They are the heaviest operations of an average app. Remember class binding with a getter from above benchmark? Great thing about browsers is they do not alter DOM if value stays the same, be it class binding, style or attribute. You should organize your view tree properly, use memoization techniques and OnPush. This way declarative approach will not be a performance bottleneck.

Summary

Components written this way are robust and flexible. They might require a little bit of a paradigm shift to get used to. But once you start thinking like this, you will see what a pleasure it is to write and maintain such code. It does take attention from you to watch out for pitfalls. Otherwise performance would suffer. But from my experience it’s so worth it, it has changed my overall attitude towards the front-end. I’ve added a new chapter to our paid advanced Angular handbook over at angular.institute. It expands upon this concept with practical tips and tricks, head over there if you want to learn more. Described examples can be played with here:

I work as the lead developer of a proprietary UI kit at Tinkoff. The whole library is built on principles outlined here. Right now, it is in the process of being open-sourced. A foundation package already available on GitHub and npm. It has that pure decorator and a lot of other neat low level tools to help you build awesome Angular applications. We will sure cover those in upcoming articles so stay tuned for more!

Discuss with community

Share

About the author

author_image
Alex Inkin

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Angular UI Kit at Tinkoff and I like sharing my findings.

author_image

About the author

Alex Inkin

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Angular UI Kit at Tinkoff and I like sharing my findings.

About the author

author_image
Alex Inkin

I’m a devoted Angular developer and a musician. I use one thing to help me not to starve while doing another and I love doing both. I work on Angular UI Kit at Tinkoff and I like sharing my findings.

THIS AD MAKES CONTENT FREE

Make Angular CLI faster

Learn how

Featured articles