Wednesday, November 3, 2021

Angular Forms: setValue, setValidators, and updateValueAndValidity

This one was a doozy. When working with Reactive Forms in Angular you may find yourself changing the values and/or validators of controls on the form based on some user input. Doing so is a pretty simple operation. Angular provides built-in functions called setValue and setValidators, whose names are hopefully self-explanatory.

In my case I have a payment form where the payment type can be cash, check, or credit card and each payment type requires different data. When the user changes from credit card to check we don't need to require the card number field any longer. Makes sense, right? Using the built-in functions, this should just be a simple matter of calling setValidators on the credit card and check fields and moving on.

this.checkoutForm.controls.address.setValidators(null);
this.checkoutForm.controls.cardNumber.setValidators(null);
this.checkoutForm.controls.city.setValidators(null);
this.checkoutForm.controls.cvvCode.setValidators(null);
this.checkoutForm.controls.expirationMonth.setValidators(null);
this.checkoutForm.controls.expirationYear.setValidators(null);
this.checkoutForm.controls.firstName.setValidators(null);
this.checkoutForm.controls.lastName.setValidators(null);
this.checkoutForm.controls.postalCode.setValidators(null);
this.checkoutForm.controls.state.setValidators(null);

this.checkoutForm.controls.checkName.setValidators(Validators.required);
this.checkoutForm.controls.checkNumber.setValidators(Validators.required);

After running this code, you'd expect address, cardNumber, city, etc. to no longer be required and checkName and checkNumber to be required. At least, that's what I'd expect. It turns out there's one more step. We have to tell Angular to update the value and validity of each of those controls whose validators were changed. This is another easy one and it uses a built-in function again, called updateValueAndValidity.

this.checkoutForm.controls.address.updateValueAndValidity();
this.checkoutForm.controls.cardNumber.updateValueAndValidity();
this.checkoutForm.controls.city.updateValueAndValidity();
this.checkoutForm.controls.cvvCode.updateValueAndValidity();
this.checkoutForm.controls.expirationMonth.updateValueAndValidity();
this.checkoutForm.controls.expirationYear.updateValueAndValidity();
this.checkoutForm.controls.firstName.updateValueAndValidity();
this.checkoutForm.controls.lastName.updateValueAndValidity();
this.checkoutForm.controls.postalCode.updateValueAndValidity();
this.checkoutForm.controls.state.updateValueAndValidity();

this.checkoutForm.controls.checkName.updateValueAndValidity();
this.checkoutForm.controls.checkNumber.updateValueAndValidity();

This makes Angular aware that the validators have changed and reevaluates the validity of each control (and the form itself). This is all fine so far. Next we have a requirement that when the user changes from credit card to check we want to clear the credit card information and vice versa. No problem. We'll just use the setValue function.

this.checkoutForm.controls.address.setValue(null);
this.checkoutForm.controls.cardNumber.setValue(null);
this.checkoutForm.controls.city.setValue(null);
this.checkoutForm.controls.cvvCode.setValue(null);
this.checkoutForm.controls.expirationMonth.setValue(null);
this.checkoutForm.controls.expirationYear.setValue(null);
this.checkoutForm.controls.firstName.setValue(null);
this.checkoutForm.controls.postalCode.setValue(null);
this.checkoutForm.controls.state.setValue(null);

Simple and straightforward again, right? I think so. To recap, we're updating the validators, updating the values, and letting Angular know that we did that. Cool. When I wrote some unit tests against this code, I found something very weird. I wrote a test to expect updateValueAndValidity to have been called one time for each control. But the test failed because updateValueAndValidity was being called twice for each control. But that doesn't make any sense at all. I'm only calling it once. I spent hours trying to figure this out, with all kinds of console.log statements in my code until I finally realized that updateValueAndValidity was being called immediately after I called setValue.

This was really confusing for me because the way I learned to do this was that you called setValidators, setValue, then updateValueAndValidity and went on your way. I had even previously written unit tests for this exact sequence of steps and they all passed. So what gives!? I wrote my tests slightly differently this time, which exposed the issue. This time when I spied on checkoutForm.controls.address.setValue, I specified .and.callThrough(), which means keep an eye on it, but let it happen the way it always would anyway. In the past I had always just spied on it, which prevents it from calling through the way it normally would, thus hiding that updateValueAndValidity was being called twice.

That's right, it turns out setValue actually calls updateValueAndValidity for us, but setValidators doesn't. When you really stop to think about it, that makes perfect sense. Just because you updated the validators doesn't mean you want to check the validity of the the controls immediately. You may want to wait until something else happens. My confusion had to do with the way I learned to use these three functions to modify form controls on the fly. Hopefully this helps you (or better yet, future me) at some point.


BONUS!!!

You don't actually have to setValue and updateValueAndValidity on every single control on the form. You can invoke patchValue and updateValueAndValidity directly on the form to make your code cleaner. Here's how my code ended up looking using those.

this.checkoutForm.patchValue({
  address: null,
  cardNumber: null,
  city: null,
  cvvCode: null,
  expirationMonth: null,
  expirationYear: null,
  firstName: null,
  lastName: null,
  postalCode: null,
  state: null
});

Angular source: https://github.com/angular/angular/blob/e49fc96ed33c26434a14b80487dd912d8c76cace/packages/forms/src/model.ts

Reference for patchValue vs. setValue: https://ultimatecourses.com/blog/angular-2-form-controls-patch-value-set-value