Angular 22 Zoneless Reactive Forms can expose a subtle UI problem: the form model. changes correctly, but parts of the template can remain stale.

For most applications, that is a good thing. Angular no longer needs ZoneJS to watch every asynchronous browser task and guess when the UI might need refreshing.

But the change exposes an important assumption in some existing Reactive Forms code.

Consider this:

this.profileForm.patchValue({
  firstName: 'Nancy'
});

The form model changes.

valueChanges can emit.

Validators can run.

But a part of your template that depends on that form state may still show the old value.

The same problem is especially visible with dynamic forms:

this.aliases.push(
  new FormControl('New alias')
);

The FormArray now contains another control, but the new row may not appear in the DOM until something else causes Angular to refresh the component.

This is not a Reactive Forms bug.

It is a change-detection notification problem.

In this article, we will reproduce it, explain why it happens in zoneless Angular, and fix it without bringing ZoneJS back.


Angular 22 Zoneless Reactive Forms: State Change vs View Notification

In a ZoneJS application, asynchronous operations such as timers, browser events, and promises were often followed by a change-detection pass.

That behavior could hide an important distinction:

Application state changed

and:

Angular was notified that the view needs checking

These are not the same thing.

In a zoneless application, Angular relies on explicit notifications from APIs it understands.

Examples include:

  • updating a signal that is read by a template
  • ChangeDetectorRef.markForCheck()
  • setting a component input
  • an Angular template or host event listener
  • AsyncPipe

Reactive Forms model mutations are different.

Calling:

control.setValue(...)

or:

form.patchValue(...)

or:

formArray.push(...)

updates the forms model, but the model operation itself is not a general request to refresh the component template.

That distinction is the key to understanding the stale-UI problem.


A Simple Reproduction

Let’s build a small standalone component.

The component has a FormArray containing aliases.

import { Component } from '@angular/core';
import {
  FormArray,
  FormControl,
  ReactiveFormsModule
} from '@angular/forms';

@Component({
  selector: 'app-alias-editor',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './alias-editor.component.html'
})
export class AliasEditorComponent {
  readonly aliases = new FormArray([
    new FormControl('Angular')
  ]);

  addAliasLater(): void {
    setTimeout(() => {
      this.aliases.push(
        new FormControl('Angular 22')
      );

      console.log(this.aliases.length);
    }, 1000);
  }
}

The template:

<button type="button" (click)="addAliasLater()">
  Add alias after 1 second
</button>

<p>Number of aliases: {{ aliases.length }}</p>

@for (control of aliases.controls; track $index) {
  <input [formControl]="control">
}

Start with one alias.

The template shows:

Number of aliases: 1

[ Angular ]

Click the button.

One second later, the callback runs:

this.aliases.push(
  new FormControl('Angular 22')
);

If you inspect the component state:

console.log(this.aliases.length);

you get:

2

The model is correct.

But the template may still show:

Number of aliases: 1

and only one input.

Then you click somewhere that causes Angular to schedule another update, and suddenly the second row appears.

That behavior can be confusing because inspecting the form tells you that everything worked.

It did work.

The missing part was telling Angular that the template needed another check.


Why the Button Click Does Not Save Us

You may notice that the original button uses an Angular event:

(click)="addAliasLater()"

Angular knows about that click, so it schedules rendering around the event.

But the actual form mutation happens one second later:

setTimeout(() => {
  this.aliases.push(...);
}, 1000);

The timer callback is a separate operation.

Without ZoneJS, the timer itself does not automatically tell Angular:

Something changed. Check this component again.

This is why the timing of the mutation matters.

If you change state directly inside an Angular template event, you may never notice the problem.

It often appears when state is changed from code such as:

  • timers
  • third-party library callbacks
  • custom event sources
  • manually managed subscriptions
  • browser APIs
  • external SDK callbacks
  • application services that mutate forms imperatively

That is also why the same application can appear to work in one path and fail in another.


Fix 1: Use markForCheck() for an Explicit Form Mutation

For a local imperative update, the simplest fix is often the best one.

Inject ChangeDetectorRef:

import {
  ChangeDetectorRef,
  Component,
  inject
} from '@angular/core';

Then notify Angular after changing the form:

export class AliasEditorComponent {
  private readonly cdr = inject(ChangeDetectorRef);

  readonly aliases = new FormArray([
    new FormControl('Angular')
  ]);

  addAliasLater(): void {
    setTimeout(() => {
      this.aliases.push(
        new FormControl('Angular 22')
      );

      this.cdr.markForCheck();
    }, 1000);
  }
}

Now the sequence is clear:

FormArray.push()
        ↓
Form model changes
        ↓
markForCheck()
        ↓
Angular schedules the component for checking
        ↓
Template renders the new control

This is a good solution when the mutation happens in a small, known number of places.

It is explicit and easy to understand.


Do Not Reach for detectChanges() First

A common reaction is to write:

this.cdr.detectChanges();

That can force an immediate change-detection run.

But it is usually not the first tool I would choose for this problem.

Prefer:

this.cdr.markForCheck();

when your real intention is:

This component changed. Please include it in Angular’s normal rendering process.

detectChanges() performs change detection immediately for that view and its children.

There are valid uses for it, but using it everywhere to fix stale UI can make rendering behavior harder to reason about.

For normal application state changes, start with the notification mechanism rather than forcing an immediate render.


Fix 2: Bridge Reactive Forms Events to Change Detection

Calling markForCheck() after every form mutation can become repetitive in a large form.

Suppose your component performs many programmatic updates:

this.profileForm.patchValue(...);

this.addresses.push(...);

this.orders.removeAt(...);

this.settingsForm.reset(...);

Instead of remembering markForCheck() after every operation, you can connect the form’s observable events to Angular’s change-detection notification.

Angular’s documentation shows this pattern for zoneless Reactive Forms.

For example:

import {
  ChangeDetectorRef,
  Component,
  inject
} from '@angular/core';

import {
  FormControl,
  FormGroup,
  ReactiveFormsModule
} from '@angular/forms';

import {
  takeUntilDestroyed
} from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-profile-editor',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './profile-editor.component.html'
})
export class ProfileEditorComponent {
  private readonly cdr = inject(ChangeDetectorRef);

  readonly profileForm = new FormGroup({
    firstName: new FormControl(''),
    lastName: new FormControl('')
  });

  constructor() {
    this.profileForm.valueChanges
      .pipe(takeUntilDestroyed())
      .subscribe(() => {
        this.cdr.markForCheck();
      });
  }
}

Now:

this.profileForm.patchValue({
  firstName: 'Nancy'
});

causes valueChanges to emit.

The subscription then calls:

this.cdr.markForCheck();

and Angular knows that the component should be refreshed.

This approach works well when the template depends heavily on Reactive Forms state and programmatic updates happen in many places.


Be Careful with emitEvent: false

There is an important edge case.

Consider:

this.profileForm.patchValue(
  {
    firstName: 'Nancy'
  },
  {
    emitEvent: false
  }
);

The form changes, but valueChanges does not emit.

That means this bridge:

this.profileForm.valueChanges
  .subscribe(() => this.cdr.markForCheck());

will not run for that update.

If the template needs to reflect the change, notify Angular explicitly:

this.profileForm.patchValue(
  {
    firstName: 'Nancy'
  },
  {
    emitEvent: false
  }
);

this.cdr.markForCheck();

This is an easy bug to introduce because emitEvent: false is sometimes used to prevent feedback loops or expensive subscriptions.

Remember what it means:

No form event
       ↓
No valueChanges emission
       ↓
No markForCheck() from that subscription

So if your change-detection strategy depends on forms observables, emitEvent: false deserves extra attention.


setValue() Is Not Broken

It is important not to describe this problem incorrectly.

This code:

this.profileForm.setValue({
  firstName: 'Nancy',
  lastName: 'Drew'
});

still updates the form.

setValue() has not stopped working in Angular 22.

The same is true for:

patchValue()

and:

FormArray.push()

The issue is what happens after the form model changes.

A useful mental model is:

setValue()
patchValue()
FormArray.push()
FormArray.removeAt()
reset()
        ↓
Reactive Forms state

and separately:

signal update
markForCheck()
template listener
AsyncPipe
component input
        ↓
Angular rendering notification

Sometimes an operation causes both through the surrounding application flow.

Sometimes it does not.

That second case is where stale templates appear.


Why FormArray Makes the Problem Easier to See

With a normal input, the problem can be subtle.

For example:

this.nameControl.setValue('Nancy');

Reactive Forms can communicate directly with the control’s value accessor, so the input element itself may update.

But another template expression such as:

<p>{{ nameControl.value }}</p>

still depends on Angular rendering the component.

That can create a strange-looking page where one part appears current while another part is stale.

FormArray makes the issue much clearer because adding a control changes the template structure.

For example:

@for (control of aliases.controls; track $index) {
  <input [formControl]="control">
}

When this runs:

this.aliases.push(
  new FormControl('Angular 22')
);

the template needs another rendering pass to create another DOM element.

The JavaScript array can contain two controls while the DOM still contains one input.

That is why dynamic forms are one of the best places to detect zoneless compatibility problems.


A Better Complete Example

Here is a small production-style component that handles the notification at the form level.

import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  inject
} from '@angular/core';

import {
  FormArray,
  FormControl,
  ReactiveFormsModule
} from '@angular/forms';

import {
  takeUntilDestroyed
} from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-alias-editor',
  standalone: true,
  imports: [ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button type="button" (click)="loadAliases()">
      Load aliases
    </button>

    <p>Total: {{ aliases.length }}</p>

    @for (control of aliases.controls; track $index) {
      <input [formControl]="control">
    }
  `
})
export class AliasEditorComponent {
  private readonly cdr = inject(ChangeDetectorRef);

  readonly aliases = new FormArray<FormControl<string>>([]);

  constructor() {
    this.aliases.valueChanges
      .pipe(takeUntilDestroyed())
      .subscribe(() => {
        this.cdr.markForCheck();
      });
  }

  loadAliases(): void {
    setTimeout(() => {
      this.aliases.push(
        new FormControl('Angular', {
          nonNullable: true
        })
      );

      this.aliases.push(
        new FormControl('Angular 22', {
          nonNullable: true
        })
      );
    }, 500);
  }
}

The important part is not OnPush.

The important part is:

this.aliases.valueChanges
  .pipe(takeUntilDestroyed())
  .subscribe(() => {
    this.cdr.markForCheck();
  });

The form emits.

The component gets marked for checking.

The template updates.

Also notice:

takeUntilDestroyed()

We do not need to manually store a Subscription and unsubscribe in ngOnDestroy.


Signals Are Another Good Bridge

Zoneless Angular works naturally with signals because updating a signal that is read in a template is a rendering notification.

Suppose the template needs to display the current form value.

You can expose the form observable as a signal:

import { toSignal } from '@angular/core/rxjs-interop';

readonly formValue = toSignal(
  this.profileForm.valueChanges,
  {
    initialValue: this.profileForm.getRawValue()
  }
);

Then use it in the template:

<p>
  Current name:
  {{ formValue().firstName }}
</p>

When valueChanges emits, the signal changes.

Because the template reads the signal, Angular knows the view needs updating.

This can be useful when you already use signals for component state.

But do not convert every Reactive Forms observable to a signal just because Angular is zoneless.

Use the simplest mechanism that fits the component.

For many existing Reactive Forms components:

markForCheck()

is enough.


Do Not Reintroduce ZoneJS to Hide the Problem

When a migration exposes stale views, one tempting solution is:

Let’s turn ZoneJS back on.

That may hide the symptom, but it does not improve the component.

Angular 22 is designed to work zoneless.

If a component changes application state without using a mechanism that Angular understands, fixing that notification path is usually the better long-term solution.

The component becomes easier to reason about because rendering is connected to meaningful state changes rather than arbitrary asynchronous browser activity.


Test the Same Behavior You Run in Production

This bug can also be hidden by tests.

A test may contain:

fixture.detectChanges();

after every operation.

That forces rendering even when the production component would never have scheduled it.

For zoneless applications, you want tests to verify that the component itself sends the correct notification.

For example:

import {
  provideZonelessChangeDetection
} from '@angular/core';

import {
  TestBed
} from '@angular/core/testing';

describe('AliasEditorComponent', () => {
  it('renders a newly added alias', async () => {
    TestBed.configureTestingModule({
      imports: [AliasEditorComponent],
      providers: [
        provideZonelessChangeDetection()
      ]
    });

    const fixture =
      TestBed.createComponent(AliasEditorComponent);

    await fixture.whenStable();

    expect(
      fixture.nativeElement.querySelectorAll('input').length
    ).toBe(0);

    fixture.componentInstance.aliases.push(
      new FormControl('Angular 22', {
        nonNullable: true
      })
    );

    fixture.componentInstance['cdr'].markForCheck();

    await fixture.whenStable();

    expect(
      fixture.nativeElement.querySelectorAll('input').length
    ).toBe(1);
  });
});

In real application code, you would not access a private ChangeDetectorRef from a test as shown above.

A cleaner design is to test a public component method that performs both operations:

addAlias(name: string): void {
  this.aliases.push(
    new FormControl(name, {
      nonNullable: true
    })
  );

  this.cdr.markForCheck();
}

Then the test becomes:

fixture.componentInstance.addAlias('Angular 22');

await fixture.whenStable();

expect(
  fixture.nativeElement.querySelectorAll('input').length
).toBe(1);

The important point is that the test should succeed because the component schedules the update correctly, not because the test manually calls detectChanges() after every state mutation.


A Practical Debugging Checklist

When a Reactive Forms component has correct values but a stale UI in Angular 22, check these questions in order.

1. Did the form model actually change?

Inspect:

console.log(this.profileForm.getRawValue());

For a FormArray:

console.log(this.aliases.length);
console.log(this.aliases.value);

If the model is wrong, this is a forms problem.

If the model is correct but the template is stale, continue.

2. Where did the mutation happen?

Look for:

setTimeout(...)

plain observable subscriptions, third-party callbacks, browser APIs, or external SDK events.

Ask:

What tells Angular that this component needs another rendering pass?

If the answer is “nothing”, you probably found the problem.

3. Can the update be expressed as a signal?

If the state naturally belongs in a signal, that may be the cleanest solution.

4. Is markForCheck() enough?

Usually:

this.cdr.markForCheck();

is preferable to forcing:

this.cdr.detectChanges();

5. Are you relying on valueChanges?

If yes, check whether the update uses:

emitEvent: false

because your change-detection bridge will not receive that event.

6. Are tests hiding the bug?

Look for unconditional:

fixture.detectChanges();

after every state change.

Your test may be repairing a notification that the production component forgot to send.


The Rule to Remember

When debugging Angular 22 Zoneless Reactive Forms, the most useful rule is simple:

Updating Reactive Forms state and notifying Angular to refresh a component are two separate operations in a zoneless application.

Sometimes Angular is already notified by the surrounding flow.

Sometimes you need to provide that notification yourself.

If your form state changes but your Angular 22 template stays stale, do not start by blaming setValue().

First ask:

Who told Angular that the view changed?

If there is no good answer, use the appropriate notification mechanism:

this.cdr.markForCheck();

or expose the state through a signal that the template consumes.

For components with many programmatic form mutations, bridge a suitable forms observable to markForCheck() instead of adding random detectChanges() calls throughout the codebase.

Zoneless change detection is not asking you to manually refresh every component.

It is asking your application to make state changes visible through mechanisms Angular can understand.

Once that distinction is clear, Reactive Forms in Angular 22 become much easier to debug.


References

Author

Write A Comment

Exit mobile version