Technical Debts in Component Design using Angular

Robert Maier-Silldorff
Stackademic
Published in
8 min readDec 11, 2023

--

In one of my previous posts I have talked about code smells in Angular [1]. This time I would like to address technical debts regarding the creation and/or wrapping of components.

Sometimes component libraries do not fit perfectly into the company’s needs. Therefore, the functionality has to be extended, and/or the component styles have to be adjusted. However, how can this be done? In this blog post I will answer this question.

I have seen lots of different approaches creating/wrapping components in Angular. Some of them are inspiring and others are rather technical debts.

These are my top 3 approaches that I have encountered so far.

  1. ControlValueAccessor rules the world: All custom components implement the ControlValueAccessor. Therefore, not only simple or dumb components, but als complex ones use this approach.
  2. FormControls are passed as input: Reactive forms are used across the whole application. Therefore, each custom component has an input with the FormControl instance.
  3. All Components must be wrapped: Developers want to have a better developer experience. Therefore, all components — provided by the component library — are wrapped and the styles are adjusted to the company’s needs.

The following picture illustrates these three different approaches by giving a simple example. Tasks have to be send to someone, in this case a receiver or a list of receivers. And this receiver contains a receiving person and a receiving organization. Moreover, simple or dumb components like input and ng-select are provided by a component library.

With the first approach all components, even the task-receiver-list, implement the ControlValueAccessor. The second one uses the FormControls as input and the third one wraps all dumb components, provided by a component library.

Of course, these three approaches may also be mixed, which will lead to additional approaches. Such as that some components use the ControlValueAccessor and some pass the FormControl as input. However, in order to keep everything simple, only these three approaches are listed.

Now, lets have a closer look at these three approaches.

1. ControlValueAccessor rules the world

Custom components do not automatically work with the Angular Forms API. As a result, the binding with ngModel, formControl and formControlName will not work out of the box. However, Angular has introduced the ControlValueAccessor interface.

The ControlValueAccessor acts as a bridge between the Angular Forms API and a native DOM element. [2]

With this interface, one might define the value that has to be assigned and the disabled state. In addition, one might also define custom validators.

Keep in mind: Validators defined at a higher component level will not be automatically propagated to the custom component. There is no propagateErrors() or propagateStateChange() function available.

In order to not always declare the registerOnChange, registerOnTouched, onChange and onTouched function for each custom component one might create a helper class.

import {ControlValueAccessor as NgControlValueAccessor} from '@angular/forms';

export abstract class ControlValueAccessor<T = any> implements NgControlValueAccessor {
abstract writeValue(value: T): void;

onChange? = (value: T | null) => {};

onTouched? = () => {};

registerOnChange(fn: (value: T | null) => void): void {
this.onChange = fn;
}

registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
}

Now, let’s have a look at an example. There are lots of simple examples out there on the web. Therefore, I would like to show a custom component that holds a FormArray. Users may define multiple receivers an those receivers contain a receiving person and a receiving organization.

const formGroup = new FormGroup<ReceiverFormGroup>({
receiverList: new FormControl<unknown>([
{
person: {
firstName: 'Max',
lastName: 'Mustermann',
},
organization: {
name: 'Personal',
},
},
]),

The following code snippet shows a minimal example of the task-receiver-list component.

@Component({
seletctor: 'xyz-task-receiver-list',
templateUrl: './task-receiver-list.component.html',
styleUrls: ['./task-receiver-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: TaskReceiverListComponent,
multi: true,
},
],
})
export class TaskReceiverListComponent extends ControlValueAccessor<ReceiverData[]> implements OnInit, AfterViewInit, OnDestroy {

readonly destroy$ = new Subject<void>();

readonly receiverFormArray = new FormArray<ReceiverDataGroup>(new FormGroup<ReceiverDataForm>({empfaenger: new FormControl<Empfaenger>({
person: new FormControl<Person>('', {nonNullable: true}),
organization: new FormControl<Organization>('', {nonNullable: true}),
});

constructor(private readonly injector: Injector) {
super();
}

ngOnInit(): void {
this.receiverFormArray.value$.pipe(takeUntil(this.destroy$)).subscribe(() => {
const value = this.receiverFormArray.controls.map((receiver) => receiver.value);
if (value !== null) {
this.onChange?.(value);
}
this.onTouched?.();
});
}

writeValue(value: ReceiverData[]): void {
this.receiverFormArray.clear();
value.forEach((receiver, index) => {
this.receiverFormArray.insert(
index,
new FormGroup<ReceiverDataForm>({
person: new FormControl(receiver.name, {nonNullable: true}),
organization: new FormControl(receiver.orgUnit, {nonNullable: true}),
})
);
});
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

...
}

However, errors from lower-level-components will not be automatically propagated to the parent. Therefore, all errors have to be set manually. This can be achieved with the following code. Be aware, that in this case all validators are set on lower-level-components, there are no validators defined on the ReceiverFormGroup parent.

ngAfterViewInit() {
this.receiverFormArray.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((status) => {
if (status !== 'PENDING') {
this.getReceiverListControl().setErrors(<Partial<any> | null>this.receiverFormArray.errors);
}
});
}

getReceiverListControl(): FormControl<unknown> {
const formControlName: FormControlName = <FormControlName>this.injector.get(NgControl);
const formDirective = <FormGroupDirective>formControlName.formDirective;
const parentFormGroup = <{controls: {receiverList: FormControl<unknown>}}>(<unknown>formDirective.form);
return parentFormGroup.controls.receiverList;
}

If one might want to propagate the errors from the parent to the child, then one has to listen to the statusChanges of the ngControl. The following code shows how this can be done in case of a simple input component.

ngOnInit(): void {
this.ngControl = this.injector.get(NgControl, null);
this.ngControl?.statusChanges?.pipe(startWith(this.ngControl.status), distinctUntilChanged(), takeUntil(this.destroy$)).subscribe((status) => {
if (status !== 'PENDING') {
this.inputValue.setErrors(this.ngControl?.errors ?? []);
}
});
}

In addition, a simpler example for the use of the ControlValueAccessor would be the following code snippet.

@Component({
selector: 'xyz-input-field',
templateUrl: './input-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: InputFieldComponent,
multi: true,
},
],
})
export class InputFieldComponent extends ControlValueAccessor<string | undefined> implements OnInit, OnDestroy {
@Input() labelText = '';

inputControl = new FormControl<string>('');

private readonly destroy$ = new Subject<void>();

ngOnInit(): void {
this.inputControl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((newValue) => {
this.onChange?.(newValue?.trim());
this.onTouched?.();
});
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

writeValue(value: string): void {
this.inputControl.setValue(value);
}

setDisabledState(isDisabled: boolean): void {
disableControl(this.inputControl, isDisabled);
}
}

2. FormControls are passed as input

In contrast to the first approach, neither the value nor the different states (errors, disabled, touched, …) have to be propagated. The validators, and all states such as dirty and touched can be managed by both the component that defines the FormGroup and all components holding the formControl-instance. As a result, the custom components do not contain any boilerplate code and are much simpler than the one of the ControlValueAccessor.

However, obviously, this approach does not support template driven forms. This will only work with Reactive Forms.

@Component({
seletctor: 'xyz-task-receiver-list',
templateUrl: './task-receiver-list.component.html',
styleUrls: ['./task-receiver-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskReceiverListComponent implements OnInit, OnDestroy {

@Input() receiverListControl: FormArray<ReceiverDataGroup>();

constructor() {}

...
}

3. All components must be wrapped

One of the most common examples for wrapping components in Angular is the ng-select. One might not only want to have a own look and feel, but also some custom logic. Therefore, it makes sense to wrap the component and provide everything through Inputs and Outputs.

@Component({
selector: 'xyz-dropdown',
templateUrl: './xyz-dropdown.component.html',
styleUrls: ['./xyz-dropdown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [{provide: SELECTION_MODEL_FACTORY, useValue: selectionFactory}],
})
export class DropdownComponent {
@Input() multiple = true;

@Input() set options(options: DropdownOption[]) {
this.loading$.next(false);
this.dropdownOptions$.next(options);
}

@Input() set selectedOptions(options: DropdownOption[] | DropdownOption) {
this.internalValue = options;
}

@Output() readonly optionChange = new EventEmitter<DropdownOption[]>();

@ContentChild(DropdownOptionDirective, {read: TemplateRef}) dropdownOptionTemplate?: TemplateRef<never>;

internalValue: DropdownOption[] | DropdownOption = this.multiple ? [notOption] : notOption;
readonly dropdownOptions$ = new BehaviorSubject<DropdownOption[]>([notOption]);
readonly loading$ = new BehaviorSubject<boolean>(false);

onOptionChange(options: DropdownOption[] | DropdownOption): void {
if (options instanceof Event) {
return;
}
if (Array.isArray(options)) {
this.optionChange.emit(options);
} else {
this.optionChange.emit([options]);
}
}

...
}

Technical debts

All three approaches are valid and have their use cases. However, sometimes people are not sure, which one is the best solution. Therefore, I would like to show some technical debts that should be avoided.

But, first, what are technical debts regarding the component design?

Personally, I would say that technical debts are not only code smells that developers are aware of, but also decisions that were made to reduce the amount of time to implement a component.

However, sometimes the decisions made were not that wise. As a result, costs to fix component-bugs are pretty high.

1. ControlValueAccessor rules the world

The ControlValueAccessor is a very powerful feature, but also a pretty advanced one. Thus, the solutions are more complex and not that easy to maintain.

Personally, I would specify the following as technical debts.

  • Mixing validators: Some components have their own validators, others are defined by the parent component, at the definition of the FormGroup.
  • Wrapping existing components without any custom logic: Adjusting the styles is not a good enough reason to choose this approach.
  • Creating custom components to minimize the template code: Sometimes one might want to set some default properties or wrap some template code that is always used by default into a custom component.

2. Form Controls are passed as input

This approach is way simpler and easier to understand. However, only Reactive Forms are supported. Keep in mind, that neither the value nor the states have to be propagated. Everything works by default as the whole FormControl instance is passed.

However, there are some cases where this approach makes no sense.

  • Creating a component library: If one might want to create a component library, template driven forms should also be supported.
  • Performance reasons: I have seen lots of code smells using this approach. All of these code smells have to do with the Change Detection. So, if one want to get rid of performance problems, maybe it is easier to choose on of the two other approaches.

3. All components must be wrapped

Sometimes it makes sense to wrap existing components, especially if one might adjust the component logic. However, sometimes this approach makes no sense. The following cases are such technical debts.

  • Adjusting styles: If one might want to adjust the look and feel of a component, the styles can be overwritten globally. Means, that there is a styles folder with all the overwritten component styles. Keep in mind that each component should have a own overwritten styles file, such as _input.scss and _ng-select.scss.
  • Adding default properties: Adding default properties will decrease the template code, but will lead to implementing additional code to propagate the values and events (such as blur).
  • Better developer experience: Do not wrap components, because one might just want to have a different selector. Wrapping components for such a case would only increase the time to maintain the components. Every event has to be propagated and also some states, such as disabled or some error-states.

Summing Up

I have given an overview about three different strategies to implement custom components: ControlValueAccessor, FormControl as input and Wrapping components. And I have addressed some technical debts regarding all three of them.

Links

[1] https://medium.com/bitsrc/code-smells-in-angular-ce73bf7db072

[2] https://angular.io/api/forms/ControlValueAccessor

Stackademic

Thank you for reading until the end. Before you go:

  • Please consider clapping and following the writer! 👏
  • Follow us on Twitter(X), LinkedIn, and YouTube.
  • Visit Stackademic.com to find out more about how we are democratizing free programming education around the world.

--

--