Search code examples
angularsignalsangular-reactive-formsangular-formsangular-library

Angular does not update the UI after form control is marked as dirty and touched


I am marking the form control as touched and dirty in my action and the change is not being reflacted on the UI.

I am using signalSlice from the ngxtension library: https://ngxtension.netlify.app/utilities/signals/signal-slice/

After I call the markAsTouched I would expect the UI to reflect that change but that only happens after the blur event (click on the input and then click outside the input). If there were more inputs all would update at the same time.

What I would like to achieve is to be able to mark my form as touched inside the signalSlice action.

Here is the minimal app on StackBlitz: https://stackblitz-starters-9bchku.stackblitz.io

And here is the code for the app:

@Injectable()
export class AppService {
  readonly #initialState = {
    submitting: false,
  };

  readonly state = signalSlice({
    initialState: this.#initialState,
    actionSources: {
      submit: (_state: any, $: Observable<void>) =>
        $.pipe(
          tap(() => console.log('Submit triggered')),
          tap(() => {
            this.formGroup.controls.name.markAsDirty();
            this.formGroup.controls.name.markAsTouched();

            console.log(
              this.formGroup.controls.name.touched,
              this.formGroup.controls.name.dirty,
              this.formGroup
            );

            console.log('Control should be marked as dirty and touched');
          }),
          filter(() => this.formGroup.valid),
          tap(() => console.log('Form valid')),
          switchMap(() =>
            timer(5000).pipe(
              tap(() => console.log('Submitted')),
              map(() => ({ submitting: false })),
              startWith({ submitting: true })
            )
          )
        ),
    },
  });

  readonly formGroup = new FormGroup<{ name: FormControl<string> }>({
    name: new FormControl<string>('', { validators: [Validators.required] }),
  });
}

@Component({
  selector: 'app-form',
  standalone: true,
  providers: [],
  imports: [JsonPipe, ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form [formGroup]="formGroup">
      <input type="text" formControlName="name" placeholder="Name" />

      <p>Touched: {{ formGroup.get('name').touched | json }}</p>
      <p>Dirty: {{ formGroup.get('name').dirty | json }}</p>
    </form>
`,
})
export class MyForm {
  #appService = inject(AppService);

  formGroup = this.#appService.formGroup;
}

@Component({
  selector: 'app-root',
  standalone: true,
  providers: [AppService],
  imports: [MyForm, JsonPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h1>Hello!</h1>

    <app-form />

    <button type="button" (click)="submit()">Submit</button>

    <p>Submitting: {{ submitting() | json }}</p>
  `,
})
export class MyApp {
  #appService = inject(AppService);

  submit = this.#appService.state.submit;
  submitting = this.#appService.state.submitting;
}

bootstrapApplication(MyApp);

I have tried a few things, like manually calling the change detection, delaying the markAsTouched call, and multiple manual timeouts.


Solution

  • <app-form> component has no reason to rerender (because of the changeDetection: ChangeDetectionStrategy.OnPush strategy).

    You need to force it somehow

    1.

    @Injectable()
    export class AppService {
      readonly #initialState = {
        submitting: false,
        cdTick: 0,
      };
    
      readonly state = signalSlice({
        initialState: this.#initialState,
        actionSources: {
          tick: (_state: any, $: Observable<void>) =>
            $.pipe(
              map(() => ({
                cdTick: _state.cdTick() ? 0 : 1,
              }))
            ),
          submit: (_state: any, $: Observable<void>) =>
            $.pipe(
              tap(() => console.log('Submit triggered')),
              tap(() => {
                this.formGroup.controls.name.markAsDirty();
                this.formGroup.controls.name.markAsTouched();
    
                console.log(
                  this.formGroup.controls.name.touched,
                  this.formGroup.controls.name.dirty,
                  this.formGroup
                );
    
                this.state.tick();
    
                console.log('Control should be marked as dirty and touched');
              }),
              filter(() => this.formGroup.valid),
              tap(() => console.log('Form valid')),
              switchMap(() =>
                timer(5000).pipe(
                  tap(() => console.log('Submitted')),
                  map(() => ({ submitting: false })),
                  startWith({ submitting: true })
                )
              )
            ),
        },
      });
    
      readonly formGroup = new FormGroup<{ name: FormControl<string> }>({
        name: new FormControl<string>('', { validators: [Validators.required] }),
      });
    }
    
    @Component({
      selector: 'app-form',
      standalone: true,
      providers: [],
      imports: [JsonPipe, ReactiveFormsModule],
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <form [formGroup]="formGroup">
          <input type="text" formControlName="name" placeholder="Name" />
    
          <p>Touched: {{ formGroup.get('name').touched | json }}</p>
          <p>Dirty: {{ formGroup.get('name').dirty | json }}</p>
          <p>cdTick: {{ cdTick() | json }}</p>
        </form>
    `,
    })
    export class MyForm {
      #appService = inject(AppService);
      cdTick = this.#appService.state.cdTick;
    
      formGroup = this.#appService.formGroup;
    }
    
    @Component({
      selector: 'app-root',
      standalone: true,
      providers: [AppService],
      imports: [MyForm, JsonPipe],
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <h1>Hello!</h1>
    
        <app-form />
    
        <button type="button" (click)="submit()">Submit</button>
    
        <p>Submitting: {{ submitting() | json }}</p>
      `,
    })
    export class MyApp {
      #appService = inject(AppService);
    
      submit = this.#appService.state.submit;
      submitting = this.#appService.state.submitting;
    }
    
    bootstrapApplication(MyApp);
    

    2.

    @Injectable()
    export class AppService {
      readonly #initialState = {
        submitting: false,
        formMarked: false,
      };
    
      readonly state = signalSlice({
        initialState: this.#initialState,
        actionSources: {
          submit: (_state: any, $: Observable<void>) =>
            $.pipe(
              tap(() => console.log('Submit triggered')),
              tap(() => {
                this.formGroup.controls.name.markAsDirty();
                this.formGroup.controls.name.markAsTouched();
    
                console.log(
                  this.formGroup.controls.name.touched,
                  this.formGroup.controls.name.dirty,
                  this.formGroup
                );
    
                console.log('Control should be marked as dirty and touched');
              }),
              switchMap(() =>
                this.formGroup.valid
                  ? timer(5000).pipe(
                      tap(() => console.log('Submitted')),
                      map(() => ({ submitting: false })),
                      startWith({
                        submitting: true,
                        formMarked:
                          this.formGroup.controls.name.touched &&
                          this.formGroup.controls.name.dirty,
                      })
                    )
                  : of({
                      formMarked:
                        this.formGroup.controls.name.touched &&
                        this.formGroup.controls.name.dirty,
                    })
              ),
              tap(console.log)
            ),
        },
      });
    
      readonly formGroup = new FormGroup<{ name: FormControl<string> }>({
        name: new FormControl<string>('', { validators: [Validators.required] }),
      });
    }
    
    @Component({
      selector: 'app-form',
      standalone: true,
      providers: [],
      imports: [JsonPipe, ReactiveFormsModule],
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <form [formGroup]="formGroup">
          <input type="text" formControlName="name" placeholder="Name" />
    
          <p>Touched: {{ formGroup.get('name').touched | json }}</p>
          <p>Dirty: {{ formGroup.get('name').dirty | json }}</p>
          <p>FormMarked: {{ formMarked() | json }}</p>
        </form>
    `,
    })
    export class MyForm {
      #appService = inject(AppService);
      formMarked = this.#appService.state.formMarked;
    
      formGroup = this.#appService.formGroup;
    
      constructor() {}
    }
    
    @Component({
      selector: 'app-root',
      standalone: true,
      providers: [AppService],
      imports: [MyForm, JsonPipe],
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <h1>Hello!</h1>
    
        <app-form />
    
        <button type="button" (click)="submit()">Submit</button>
    
        <p>Submitting: {{ submitting() | json }}</p>
      `,
    })
    export class MyApp {
      #appService = inject(AppService);
    
      submit = this.#appService.state.submit;
      submitting = this.#appService.state.submitting;
    }
    
    bootstrapApplication(MyApp);
    

    3. (utilize action stream)

    @Injectable()
    export class AppService {
      readonly #initialState = {
        submitting: false,
      };
    
      readonly state = signalSlice({
        initialState: this.#initialState,
        actionSources: {
          submit: (_state: any, $: Observable<void>) =>
            $.pipe(
              tap(() => {
                this.formGroup.controls.name.markAsDirty();
                this.formGroup.controls.name.markAsTouched();
              }),
              filter(() => this.formGroup.valid && !this.formGroup.invalid),
              switchMap(() =>
                timer(1500).pipe(
                  map(() => ({ submitting: false })),
                  startWith({
                    submitting: true,
                  })
                )
              )
            ),
        },
      });
    
      readonly formGroup = new FormGroup<{ name: FormControl<string> }>({
        name: new FormControl<string>('', { validators: [Validators.required] }),
      });
    }
    
    @Component({
      selector: 'app-form',
      standalone: true,
      providers: [],
      imports: [JsonPipe, ReactiveFormsModule],
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <form [formGroup]="formGroup">
          <input type="text" formControlName="name" placeholder="Name" />
    
          <p>Touched: {{ formGroup.get('name').touched | json }}</p>
          <p>Dirty: {{ formGroup.get('name').dirty | json }}</p>
          <p>Valid: {{ formGroup.get('name').valid | json }}</p>
          <p>Invalid: {{ formGroup.get('name').invalid | json }}</p>
          <p>Pristine: {{ formGroup.get('name').pristine | json }}</p>
        </form>
    `,
    })
    export class MyForm {
      #appService = inject(AppService);
      formMarked = this.#appService.state.formMarked;
    
      formGroup = this.#appService.formGroup;
    
      constructor(private cdr: ChangeDetectorRef) {
        /** type issue - we need to cast to 'Observable<void>' */
        (this.#appService.state.submit$ as Observable<void>)
          .pipe(
            tap(() => this.detectChanges()),
            takeUntilDestroyed()
          )
          .subscribe();
      }
    
      private detectChanges() {
        this.cdr.detach();
        this.cdr.detectChanges();
        this.cdr.reattach();
      }
    }
    
    @Component({
      selector: 'app-root',
      standalone: true,
      providers: [AppService],
      imports: [MyForm, JsonPipe],
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <h1>Hello!</h1>
    
        <app-form />
    
        <button type="button" (click)="submit()">Submit</button>
    
        <p>Submitting: {{ submitting() | json }}</p>
      `,
    })
    export class MyApp {
      #appService = inject(AppService);
    
      submit = this.#appService.state.submit;
      submitting = this.#appService.state.submitting;
    }
    
    bootstrapApplication(MyApp);
    

    stackblitz