A plain form is something you interrogate: you ask it for its value at the moment someone clicks "submit". A reactive form works the other way round - it is a living dojo that reports every move as it happens. The key idea of this lesson: you do not ask the form for its value, you listen to its changes. Every control carries a
valueChanges stream that flows like a river - and you filter that river with RxJS operators.Writing data into a form is the easy direction, and it has two doors.
setValue() demands the complete object, every single field, or it throws. patchValue() updates only the fields you hand it and leaves the rest untouched. That is the whole difference: setValue requires all fields, patchValue updates only the given ones. Neither one is faster than the other, patchValue is not some FormArray-only tool, and they are certainly not the same method under two names.Start with the
valueChanges stream. You can listen to a single control, to the whole form, or to its status. Statuses travel on their own stream, statusChanges, and it emits exactly four values - VALID, INVALID, PENDING, DISABLED - never TRUE/FALSE, never OK/ERROR. Read how such a river gets filtered.1ngOnInit(): void {
2 // Load data in - patchValue touches only the fields you pass
3 this.form.patchValue({ searchQuery: 'katana' });
4
5 // A single control - with filtering
6 this.form.get('searchQuery')?.valueChanges.pipe(
7 debounceTime(300), // wait 300ms after the last keystroke
8 distinctUntilChanged(), // ignore it if the value did not change
9 takeUntilDestroyed(this.destroyRef)
10 ).subscribe(query => console.log('Searching:', query));
11
12 // The whole form at once
13 this.form.valueChanges.pipe(
14 takeUntilDestroyed(this.destroyRef)
15 ).subscribe(value => console.log('Form changed:', value));
16
17 // Status: VALID, INVALID, PENDING or DISABLED
18 this.form.statusChanges.pipe(
19 takeUntilDestroyed(this.destroyRef)
20 ).subscribe(status => console.log('Status:', status));
21}Mind the property name: it is
valueChanges, not onChange, not value$ - and not subscribe, which is what you call on the stream, never the stream itself. Three operators do all the real work here. debounceTime(300) waits until the user stops typing; without it you would fire a request after every letter. distinctUntilChanged() drops repeats. And takeUntilDestroyed(this.destroyRef) is your mandatory guard: it detaches from the stream the moment the component disappears - leave it out and you have a memory leak. Remember those three, they come back in every reactive form.Sometimes the rules depend on what the user picks: chose contact by email - require an email; chose the phone - require a number. So you listen to one control and swap the validators on another one. Inside that subscription the order of the lines is not decoration, it is the mechanism.
1this.form.get('contactMethod')?.valueChanges.pipe(
2 takeUntilDestroyed(this.destroyRef)
3).subscribe(method => {
4 const email = this.form.get('email')!;
5 const phone = this.form.get('phone')!;
6
7 if (method === 'email') {
8 email.setValidators([Validators.required, Validators.email]);
9 phone.clearValidators();
10 } else {
11 phone.setValidators([Validators.required]);
12 email.clearValidators();
13 }
14
15 email.updateValueAndValidity(); // without this the change does nothing
16 phone.updateValueAndValidity();
17});Pay close attention to the last two lines - that is the step everyone forgets.
setValidators only swaps the rules, it does not recompute the status of the control. Only updateValueAndValidity() tells Angular to check the control again. Spelled out for one control, the sequence reads: emailControl.clearValidators() to drop the old rules, then emailControl.setValidators([Validators.required, Validators.email]) to install the new ones, and finally emailControl.updateValueAndValidity(). Skip that last call and you hit the classic bug: the rules look changed, yet the form stubbornly shows its old state.A stream is a pleasure to listen to, but inside a template a signal is far more comfortable.
toSignal builds the bridge: it takes valueChanges and turns it into a signal that you read in the template like any other - no manual subscription, no manual cleanup.1nameSignal = toSignal(
2 this.form.get('name')!.valueChanges,
3 { initialValue: '' }
4);
5
6isValid = toSignal(
7 this.form.statusChanges.pipe(map(s => s === 'VALID')),
8 { initialValue: false }
9);toSignal() is the one function for this job - signal() only creates a fresh writable signal, computed() derives from signals you already have, and asSignal() does not exist at all. From here on you write {{ nameSignal() }} in the template instead of subscribing by hand. The initialValue option is mandatory, because a signal must hold a value from its very first moment, before the stream has emitted anything. This is the cleanest way to show form state in the view.And now the master technique: a widget of your own that behaves exactly like a built-in control. Your rating stars should work with
formControlName just like a plain input. The interface that makes this possible is ControlValueAccessor - a contract of a few methods through which Angular talks to your widget. Not FormControlDirective, not OnInit, not Validator; those solve entirely different problems.1@Component({
2 selector: 'app-star-rating',
3 template: `
4 @for (star of stars; track $index; let i = $index) {
5 <span (click)="setRating(i + 1)" [class.filled]="i < value">★</span>
6 }
7 `,
8 providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: StarRatingComponent, multi: true }]
9})
10export class StarRatingComponent implements ControlValueAccessor {
11 stars = [1, 2, 3, 4, 5];
12 value = 0;
13 private onChange: (v: number) => void = () => {};
14
15 writeValue(value: number): void { this.value = value || 0; }
16 registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
17 registerOnTouched(fn: () => void): void {}
18
19 setRating(rating: number): void {
20 this.value = rating;
21 this.onChange(rating); // tell the form about the new value
22 }
23}The contract reads like a conversation running both ways, and the methods are declared in a fixed order:
writeValue(value) first, then registerOnChange(fn), then registerOnTouched(fn). writeValue is the form speaking to the widget - "take this value" - whenever data arrives from outside. registerOnChange hands you a function you call in the opposite direction: onChange(rating) tells the form "the user picked this many stars". Registering in NG_VALUE_ACCESSOR makes Angular treat your component as a native control, and that alone is enough for <app-star-rating formControlName="rating"> to work.Remember one thing from this lesson: a reactive form is a stream you listen to, not a box you interrogate. You filter it with operators, you bridge it to signals with
toSignal, and you plug your own controls into it with the ControlValueAccessor contract.