Optimise conditional validators for Angular Forms
Intro
In this video, I explained how to write Angular validations with Vest.js suites.
Note, this article is outdated
Everything is now part of ngx-signal-forms.
We have dived into regular validations but also conditional validations.
This example shows that the confirmPassword
field is only required when the password
field has a value.
and that both passwords should match but only when they are both filled in.
omitWhen(!model.passwords?.password, () => {
test('passwords.confirmPassword', 'Confirm password is required', () => {
enforce(model.passwords?.confirmPassword).isNotBlank();
})
});
omitWhen(!model.passwords?.password || !model.passwords.confirmPassword, () => {
test('passwords', 'Passwords should match', () => {
enforce(model.passwords?.password).equals(model.passwords?.confirmPassword);
});
});
Ton connect Angular to Vest, we created a directive that hooks into ngModel.
That directive implements the Validator
interface and will execute a part of a vest suite when
the validate()
method is called. Check the code here
Now the issue with Angular is that validations are only run for a control when that control is changed.
So when we type into the password
field, the system has no idea to run the validator of the confirmPassword
field.
Since confirmPassword
should only be validated when password
changes, it is not executed when the confirmPassword
control gets a value.
We solved this before
We solved this before by using the alwaysTriggerValidations
input property that can be found here, but that meant that all the validators
were run every time any control in our form changed. That is not good for performance, and it could be even worse if
we started using asynchronous validations. This is something we will tackle in the next article!
So before we solved the issue like this (using the alwaysTriggerValidations
input:
<form
[alwaysTriggerValidations]="true"
[formValue]="formValue()"
[suite]="suite"
(formValueChange)="formValue.set($event)">
</form>
A more efficient solution
What the alwaysTriggerValidations
input will do, is it will recursively loop over all the controls in our form
and call the updateValueAndValidity()
method on them. This will result in the entire form to get validated.😅
What we really want, is to only trigger validations when they need to be triggered.
For that reason we can create a validationConfig
object that tells us exactly which validators need to run
when a certain control is updated.
A part of our validation suite from the previous video looked like this:
omitWhen((model.age || 0) >= 18, () => {
test('emergencyContact', 'Emergency contact is required', () => {
enforce(model.emergencyContact).isNotBlank();
})
})
omitWhen(!model.passwords?.password, () => {
test('passwords.confirmPassword', 'Confirm password is required', () => {
enforce(model.passwords?.confirmPassword).isNotBlank();
})
});
So when the age
control changes, we would have to validate the emergencyContact
control.
When the passwords.password
control changes, we would have to validate the passwords.confirmPassword
control.
Our form component would have a validationConfig
object that look like this:
export class SimpleFormComponent {
protected readonly suite = simpleFormValidations;
protected readonly formValue = signal<SimpleFormModel>({})
protected readonly validationConfig: {
[key: string]: string[]
} = {
'age': ['emergencyContact'],
'passwords.password': ['passwords.confirmPassword']
}
}
This api is part of ngx-signal-forms.
And we would update the HTML like this:
<form
[validationConfig]="validationConfig"
[formValue]="formValue()"
[suite]="suite"
(formValueChange)="formValue.set($event)">
</form>
Now the FormDirective
that hooks into the form
selector needs get a new input property where we will pass the validationConfig
.
It would have to loop over all the properties, listen to changes from those properties and update the other
controls when needed.
We can use a setter for that:
@Input() public set validationConfig(v: {[key: string]: string[]}){
// Loop over all the keys
Object.keys(v).forEach(key => {
// Listen to changes of the form
this.ngForm.form.valueChanges.pipe(
// Only listen to the changes of our key
map(() => this.ngForm.form.get(key)?.value),
// Only get notified when there is an actual change
distinctUntilChanged(),
// Avoid memory leaks
takeUntil(this.destroy$$)
)
.subscribe((form) => {
// So the control has changed, let's loop over all the
// dependencies of that control
v[key].forEach((path) => {
// Trigger the validations of the dependency of that control
this.ngForm.form.get(path)?.updateValueAndValidity({onlySelf: true, emitEvent: false})
})
})
})
}
you can check out the code here
Wrapping up
This was a short and simple article, but it had a major impact. Now only the validators of the controls that actually need to be validated will be run. We are now ready to introduce Asynchronous validations next monday. It would not make sense to run asynchronous validations every times a control changes right?! That would result in ajax calls the entire time.
The entire codebase can be found here.
Check out the YouTube video here
Follow @TwitterDevJoin the club and get notified with new content
Get notified about our future blog articles by subscribing with the button below.
We won't send spam, unsubscribe whenever you want.
Update cookies preferences