We use cookies to enhance your experience on the site
CodeWorlds

Forms That Listen

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.

Listening for changes

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.

Validators that change on the fly

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 bridge to signals: toSignal

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.

A control of your own: ControlValueAccessor

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.

Go to CodeWorlds