Skip to content

Commit 7a0de42

Browse files
authored
Merge pull request #236 from valor-software/pitfalls-article
feat(article): pitfalls article
2 parents a49478a + 0d51f5d commit 7a0de42

7 files changed

Lines changed: 271 additions & 0 deletions

File tree

328 KB
Loading
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
One of the biggest advantages of Angular is the variety of tools and solutions that are brought to developers out of the box. One of them is the #@angular/forms# package, which brings the solid experience of working with any kind of UI controls.
2+
But have you ever wondered, how exactly this works under the hood? The only thing that needs to be done in order to tie FormControl with, let's say, a plain input is using a "formControl" binding on the input element, pointing that UI element to the instance of a FormControl.
3+
4+
[, js]
5+
----
6+
<input type="text" [formControl]="ctrl" />
7+
----
8+
And voila, everything works.
9+
10+
But obviously, there should be a component or directive that Angular uses to make everything happen. And that "something" can be found https://github.com/angular/angular/tree/main/packages/forms/src/directives[here, window=_blank]: Angular brings a set of directives like https://github.com/angular/angular/blob/main/packages/forms/src/directives/default_value_accessor.ts[default_value_accessor.ts, window=_blank], https://github.com/angular/angular/blob/main/packages/forms/src/directives/select_control_value_accessor.ts[select_control_value_accessor.ts, window=_blank], https://github.com/angular/angular/blob/main/packages/forms/src/directives/checkbox_value_accessor.ts[checkbox_value_accessor.ts, window=_blank], etc. All of them implement the ControlValueAccessor interface, which, according to docs: __"Defines an interface that acts as a bridge between the Angular forms API and a native element in the DOM."__
11+
12+
This means any component can be easily defined as a form control by implementing this interface and registering itself as an #NG_VALUE_ACCESSOR# provider. In practice, it requires you to define 4 methods:
13+
14+
[, js]
15+
----
16+
interface ControlValueAccessor {
17+
writeValue(obj: any): void
18+
registerOnChange(fn: any): void
19+
registerOnTouched(fn: any): void
20+
setDisabledState(isDisabled: boolean)?: void
21+
}
22+
----
23+
__*although setDisabledState is optional, there’re only a few rare scenarios when it’s indeed not needed__
24+
25+
To understand how exactly everything works, let’s have a look at the very basic counter component:
26+
27+
[, js]
28+
----
29+
<lib-counter [formControl]="counter"></lib-counter>
30+
<div>Counter Value: {{ counter.value }}</div>
31+
----
32+
33+
[.img]
34+
image::image1.gif[]
35+
36+
Here’s the code of the component itself:
37+
38+
[, js]
39+
----
40+
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef } from '@angular/core';
41+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
42+
43+
const COUNTER_CONTROL_ACCESSOR = {
44+
provide: NG_VALUE_ACCESSOR,
45+
useExisting: forwardRef(() => CounterControlComponent),
46+
multi: true,
47+
};
48+
49+
@Component({
50+
selector: 'lib-counter',
51+
template: `
52+
<button (click)="down()" [disabled]="disabled">Down</button>
53+
{{ value }}
54+
<button (click)="up()" [disabled]="disabled">Up</button>
55+
`,
56+
changeDetection: ChangeDetectionStrategy.OnPush,
57+
providers: [COUNTER_CONTROL_ACCESSOR],
58+
})
59+
export class CounterControlComponent implements ControlValueAccessor {
60+
disabled = false;
61+
value = 0;
62+
63+
protected onTouched: () => void;
64+
protected onChange: (value: number) => void;
65+
66+
constructor(private _cdr: ChangeDetectorRef) {}
67+
68+
up() {
69+
this.setValue(this.value + 1, true);
70+
}
71+
72+
down() {
73+
this.setValue(this.value - 1, true);
74+
}
75+
76+
registerOnChange(fn: (value: number) => void) {
77+
this.onChange = fn;
78+
}
79+
80+
registerOnTouched(fn: () => void) {
81+
this.onTouched = fn;
82+
}
83+
84+
setDisabledState(isDisabled: boolean) {
85+
this.disabled = isDisabled;
86+
}
87+
88+
writeValue(value: number) {
89+
this.setValue(value, false);
90+
this._cdr.markForCheck();
91+
}
92+
93+
protected setValue(value: number, emitEvent: boolean) {
94+
const parsed = parseInt(value as any);
95+
this.value = isNaN(parsed) ? 0 : parsed;
96+
if (emitEvent && this.onChange) {
97+
this.onChange(value);
98+
this.onTouched();
99+
}
100+
}
101+
}
102+
----
103+
104+
As you see here we’re implementing 4 methods and providing #COUNTER_CONTROL_ACCESSOR.# This is needed in order to let Angular know it deals with an instance of a form control.
105+
106+
So what’s happening with control is:
107+
108+
1. Once FormControl is initialised, it invokes #writeValue#, #registerOnChange# and registerOnTouched methods on the counter component. This syncs the initial state of the FormControl with our counter and also passes onTouched and onChanged methods into the counter, so it can talk back to the FormControl when the user interacts with it. +
109+
2. When the value is changed, FormControl invokes the #writeValue# method, so counter updates its internal state without triggering the #onChange#/#onTouched# methods. +
110+
3. When the user interacts with our counter, it’s required to not only update the internal state but also notify parent FormControl about this state change, thus #onChange#/#onTouched# methods are invoked.
111+
112+
Although that’s not really a lot going on here, it is worth taking a look at a few important implementation details. And this is actually what this article is about
113+
114+
== Common pitfalls with CVAs and how to avoid them
115+
116+
*#onChange# should be only triggered by an internal event!*
117+
118+
It’s important to keep in mind that these methods should only be used to notify FormControl about the change that was triggered in the component internally. In other words, if FormControl changes the value of the component, it should never notify FormControl back about this change. This is a quite common mistake as it won’t break anything at the first glance, instead you’ll be able to notice it by subscribing to #valueChanges# of the bound FormControl:
119+
120+
[, js]
121+
----
122+
export class AppComponent {
123+
readonly animal = new FormControl(‘rabbit’);
124+
125+
constructor() {
126+
ctrl.valueChanges.subscribe(console.log);
127+
animal.setValue(‘hare’);
128+
animal.setValue(‘cat’);
129+
}
130+
}
131+
----
132+
133+
In the normal scenario by executing the code above you will see only 2 logs: ‘hare’, ‘cat’. However, if your #writeValue# method ends up invoking #onChange# you will see doubled console logs in the output: ‘hare’, ’hare’, ‘cat’, ‘cat’.
134+
135+
Here’s a modified code of #CounterComponent# where this issue can be seen, when FormControl invokes #writeValue# we notify it back with the #onChange# method:
136+
137+
[, js]
138+
----
139+
// ... code of CounterComponent
140+
writeValue(value: number) {
141+
// it's convenient to reuse existing "setValue" method, right?
142+
// however, this will lead to the incorrect behavior
143+
this.setValue(value);
144+
this._cdr.markForCheck();
145+
}
146+
147+
protected setValue(value: number) {
148+
const parsed = parseInt(value as any);
149+
this.value = isNaN(parsed) ? 0 : parsed;
150+
if (this.onChange) {
151+
this.onChange(value);
152+
this.onTouched();
153+
}
154+
}
155+
----
156+
157+
*#onChange# and #onTouched# should not always be called together!*
158+
159+
#onChange#/#onTouched# methods actually serve completely different purposes. While #onChange# is used to pass data when a component’s state changed internally, #onTouched# should be invoked after the user interacts with the component. This doesn’t always mean the component’s value is changed.
160+
161+
#onTouched# method is used in 2 cases: +
162+
163+
* by FormControl to update its touched state
164+
* when you set up your control to use https://angular.io/api/forms/AbstractControl[updateOn: “blur", window=_blank], FormControl uses it to properly identify this blur event to apply the value to itself.
165+
166+
For the CounterComponent both touch and change events are combined because the only way to interact with it is by clicking the button. However, with other components, the flow will be different. For instance, a plain #<input /># element with a tied FormControl (with DefaultValueAccessor under the hood) is expected to be marked as touched when the user interacts with the input even by focusing it. Thus, for this kind of components onTouched emission should be tied to the #blur# event from the input.
167+
168+
[.img]
169+
image::image2.gif[]
170+
171+
=== Handling nulls properly
172+
173+
With an introduction of typed forms, form controls can now either infer a type from the default value or be typed explicitly. There’s an interesting thing, though: if we define a control c#onst control = new FormControl<string>()# and then check its type, it will be #string | null#. And you might wonder: why does the type of this control include #null#? This is because the control can become null at any time, by calling the #reset()# method on it. Here’s an example from angular docs:
174+
175+
[, js]
176+
----
177+
const control = new FormControl('Hello, world!');
178+
control.reset();
179+
console.log(control.value); // null
180+
----
181+
182+
Although this becomes quite obvious with typed forms, this behavior was inherent in forms from the very beginning. And while new handy types may catch issues with control’s values, it doesn’t really save you from any issues with nulls inside your CVA. Moreover, since CVA component doesn’t have any control over the form it’s being used within and there’s no way to enforce certain types of control on the form, it’s possible to actually pass literally any value into the control. Hence this value will end up passing into the #writeValue#, which can potentially break your component.
183+
184+
Let’s change our CounterComponent as follows:
185+
186+
[, js]
187+
----
188+
// ... code of CounterComponent
189+
writeValue(value: number) {
190+
// it's convenient to reuse existing "setValue" method, right?
191+
// however, this will lead to the incorrect behavior
192+
this.setValue(value, false);
193+
this._cdr.markForCheck();
194+
}
195+
196+
protected setValue(value: number, emitEvent: boolean) {
197+
this.value = value;
198+
if (emitEvent && this.onChange) {
199+
this.onChange(value);
200+
this.onTouched();
201+
}
202+
}
203+
----
204+
205+
[.img]
206+
image::image3.gif[]
207+
208+
CounterComponent is too simple to have big issues with null because JavaScript will cast null into 0 (#null + 1 = 1#), but as you can see component is visually broken after #reset()# is called. So it’s very important to keep in mind this behavior and implement some value protections for the #writeValue# method.
209+
210+
== Standardising your custom UI form components with ControlValueAccessor Test Suite
211+
212+
Even if you keep in mind all the potential pitfalls listed above, there’s always a chance something will go wrong due to some change or enhancement in the future. The best way to maintain the valid behavior of a component is to have extensive unit test coverage. However, it might be annoying to write the same list of tests for all CVA components or some use cases can be accidentally left without coverage. So it should be much better to have one unified testing solution, that can keep your components safe.
213+
214+
And there’s one called https://github.com/dmitry-stepanenko/ngx-cva-test-suite[ngx-cva-test-suite, window=_blank]. It’s a small npm package, that provides an extensive set of test cases, ensuring your custom controls behave as intended. It is designed and tested to work properly with both Jest and Jasmine test runners.
215+
216+
Among the main features:
217+
218+
* ensures the correct amount of calls for the #onChange# function (incorrect usage may result in extra emissions of #valueChanges# of formControl)
219+
* ensures correct triggering of #onTouched# function (is needed for touched state of the control and #updateOn: 'blur'# https://angular.io/api/forms/AbstractControl[strategy, window=_blank] to function properly)
220+
* ensures that no extra emissions are present when control is disabled
221+
* checks for control to be resettable using #AbstractControl.reset()#
222+
223+
It is quite easy to be configured, here’s the usage scenario for the CounterComponent we looked into within this article:
224+
225+
[, js]
226+
----
227+
import { runValueAccessorTests } from 'ngx-cva-test-suite';
228+
import { CounterControlComponent } from './counter.component';
229+
230+
runValueAccessorTests({
231+
/** Component, that is being tested */
232+
component: CounterControlComponent,
233+
/**
234+
* All the metadata required for this test to run.
235+
* Under the hood calls TestBed.configureTestingModule with provided config.
236+
*/
237+
testModuleMetadata: {
238+
declarations: [CounterControlComponent],
239+
},
240+
/** Whether component is able to track "onBlur" events separately */
241+
supportsOnBlur: false,
242+
/**
243+
* Tests the correctness of an approach that is used to set value in the component,
244+
* when the change is internal. It's optional and can be omitted by passing "null"
245+
*/
246+
internalValueChangeSetter: null,
247+
/** Function to get the value of a component in a runtime. */
248+
getComponentValue: (fixture) => fixture.componentInstance.value,
249+
/** When component is reset by FormControl, it should either get a certain default internal value or "null" */
250+
resetCustomValue: { value: 0 },
251+
/**
252+
* This test suite applies up to 3 different values on the component to test different use cases.
253+
* Values can be customized using this configuration option.
254+
*/
255+
getValues: () => [1, 2, 3],
256+
});
257+
----
258+
259+
You can learn more about usage examples in the https://github.com/dmitry-stepanenko/ngx-cva-test-suite[package’s repository, window=_blank] or get inspiration by looking at a few CVA components that are placed https://github.com/dmitry-stepanenko/ngx-cva-test-suite/tree/master/apps/integration/src/app/controls[within the repository here, window=_blank].
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"title": "Avoiding common pitfalls with ControlValueAccessors in Angular",
3+
"order": 60,
4+
"domains": ["dev_quality_assurance"],
5+
"authorImg": "assets/articles/avoiding-common-pitfalls-with-controlvalueaccessors-in-angular/Dmitry_Stepanenko.png",
6+
"language": "en",
7+
"bgImg": "assets/articles/avoiding-common-pitfalls-with-controlvalueaccessors-in-angular/pitfalls.png",
8+
"author": "Dmitriy Stepanenko",
9+
"position": "Full Stack developer",
10+
"date": "Tue Jan 17 2023 10:45:55 GMT+0000 (Coordinated Universal Time)",
11+
"seoDescription": "Variety of tools and solutions"
12+
}
42.1 KB
Loading
77.4 KB
Loading
1.18 MB
Loading
552 KB
Loading

0 commit comments

Comments
 (0)